diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 39dc08444d3..aa4bfc60c11 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -116,7 +116,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -454,7 +454,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dad662a9202..863c861db75 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 11 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.2" + HA_SHORT_VERSION: "2025.3" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version @@ -234,7 +234,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -279,7 +279,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -319,7 +319,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -359,7 +359,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -469,7 +469,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -572,7 +572,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -605,7 +605,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -643,7 +643,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -686,7 +686,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -733,7 +733,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -778,7 +778,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -859,7 +859,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -923,7 +923,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1044,7 +1044,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1173,7 +1173,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1319,7 +1319,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9dbd39b4bc5..c1272759acc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.5 + uses: github/codeql-action/init@v3.28.8 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.5 + uses: github/codeql-action/analyze@v3.28.8 with: category: "/language:python" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index fa3c2305190..619d83aef51 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 00f0c507414..41e7b351184 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -131,7 +131,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp312", "cp313"] + abi: ["cp313"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository @@ -180,7 +180,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp312", "cp313"] + abi: ["cp313"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository diff --git a/.strict-typing b/.strict-typing index 43efce48fb0..cdb0dd8fb96 100644 --- a/.strict-typing +++ b/.strict-typing @@ -217,6 +217,7 @@ homeassistant.components.goalzero.* homeassistant.components.google.* homeassistant.components.google_assistant_sdk.* homeassistant.components.google_cloud.* +homeassistant.components.google_drive.* homeassistant.components.google_photos.* homeassistant.components.google_sheets.* homeassistant.components.govee_ble.* @@ -227,6 +228,7 @@ homeassistant.components.guardian.* homeassistant.components.habitica.* homeassistant.components.hardkernel.* homeassistant.components.hardware.* +homeassistant.components.heos.* homeassistant.components.here_travel_time.* homeassistant.components.history.* homeassistant.components.history_stats.* @@ -359,6 +361,7 @@ homeassistant.components.number.* homeassistant.components.nut.* homeassistant.components.onboarding.* homeassistant.components.oncue.* +homeassistant.components.onedrive.* homeassistant.components.onewire.* homeassistant.components.onkyo.* homeassistant.components.open_meteo.* diff --git a/CODEOWNERS b/CODEOWNERS index faded2af138..7baeea72178 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -566,6 +566,8 @@ build.json @home-assistant/supervisor /tests/components/google_assistant_sdk/ @tronikos /homeassistant/components/google_cloud/ @lufton @tronikos /tests/components/google_cloud/ @lufton @tronikos +/homeassistant/components/google_drive/ @tronikos +/tests/components/google_drive/ @tronikos /homeassistant/components/google_generative_ai_conversation/ @tronikos /tests/components/google_generative_ai_conversation/ @tronikos /homeassistant/components/google_mail/ @tkdrob @@ -1071,6 +1073,8 @@ build.json @home-assistant/supervisor /tests/components/oncue/ @bdraco @peterager /homeassistant/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP +/homeassistant/components/onedrive/ @zweckj +/tests/components/onedrive/ @zweckj /homeassistant/components/onewire/ @garbled1 @epenet /tests/components/onewire/ @garbled1 @epenet /homeassistant/components/onkyo/ @arturpragacz @eclair4151 diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 799fd4d2e16..83299859de9 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -21,7 +21,7 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import is_cloud_connection from .. import InvalidAuthError diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 3d24d807a06..4d309469017 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -18,6 +18,7 @@ import securetar from .const import __version__ as HA_VERSION RESTORE_BACKUP_FILE = ".HA_RESTORE" +RESTORE_BACKUP_RESULT_FILE = ".HA_RESTORE_RESULT" KEEP_BACKUPS = ("backups",) KEEP_DATABASE = ( "home-assistant_v2.db", @@ -62,7 +63,10 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | restore_database=instruction_content["restore_database"], restore_homeassistant=instruction_content["restore_homeassistant"], ) - except (FileNotFoundError, KeyError, json.JSONDecodeError): + except FileNotFoundError: + return None + except (KeyError, json.JSONDecodeError) as err: + _write_restore_result_file(config_dir, False, err) return None finally: # Always remove the backup instruction file to prevent a boot loop @@ -142,6 +146,7 @@ def _extract_backup( config_dir, dirs_exist_ok=True, ignore=shutil.ignore_patterns(*(keep)), + ignore_dangling_symlinks=True, ) elif restore_content.restore_database: for entry in KEEP_DATABASE: @@ -159,6 +164,23 @@ def _extract_backup( ) +def _write_restore_result_file( + config_dir: Path, success: bool, error: Exception | None +) -> None: + """Write the restore result file.""" + result_path = config_dir.joinpath(RESTORE_BACKUP_RESULT_FILE) + result_path.write_text( + json.dumps( + { + "success": success, + "error": str(error) if error else None, + "error_type": str(type(error).__name__) if error else None, + } + ), + encoding="utf-8", + ) + + def restore_backup(config_dir_path: str) -> bool: """Restore the backup file if any. @@ -177,7 +199,14 @@ def restore_backup(config_dir_path: str) -> bool: restore_content=restore_content, ) except FileNotFoundError as err: - raise ValueError(f"Backup file {backup_file_path} does not exist") from err + file_not_found = ValueError(f"Backup file {backup_file_path} does not exist") + _write_restore_result_file(config_dir, False, file_not_found) + raise file_not_found from err + except Exception as err: + _write_restore_result_file(config_dir, False, err) + raise + else: + _write_restore_result_file(config_dir, True, None) if restore_content.remove_after_restore: backup_file_path.unlink(missing_ok=True) _LOGGER.info("Restore complete, restarting") diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 028fa544a5f..872cfc0aac5 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -5,6 +5,7 @@ "google_assistant", "google_assistant_sdk", "google_cloud", + "google_drive", "google_generative_ai_conversation", "google_mail", "google_maps", diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index 4d9eb5f95f3..0e00c4a7bc3 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -11,6 +11,7 @@ "microsoft_face", "microsoft", "msteams", + "onedrive", "xbox" ] } diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index 71f7de89528..3e65374f391 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -12,8 +12,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py index c1463cd9a08..846164202d8 100644 --- a/homeassistant/components/acer_projector/switch.py +++ b/homeassistant/components/acer_projector/switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/acmeda/__init__.py b/homeassistant/components/acmeda/__init__.py index 62a62795a05..ec7abe258cf 100644 --- a/homeassistant/components/acmeda/__init__.py +++ b/homeassistant/components/acmeda/__init__.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .hub import PulseHub diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 273ca6a772f..41876ce478f 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import LEASES_REGEX diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 892390a91eb..da34bd36e2c 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_ADS_VAR, DATA_ADS, DOMAIN, AdsType diff --git a/homeassistant/components/ads/binary_sensor.py b/homeassistant/components/ads/binary_sensor.py index 72a12506dc1..560d090caf0 100644 --- a/homeassistant/components/ads/binary_sensor.py +++ b/homeassistant/components/ads/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index c7b0f4f2f8a..15d5b3a7d09 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/light.py b/homeassistant/components/ads/light.py index 5ea4868bf11..3de223e5fc4 100644 --- a/homeassistant/components/ads/light.py +++ b/homeassistant/components/ads/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/select.py b/homeassistant/components/ads/select.py index 39f813dec27..e31e089d669 100644 --- a/homeassistant/components/ads/select.py +++ b/homeassistant/components/ads/select.py @@ -11,7 +11,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index 09579161a94..0fd1b84ffd1 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType diff --git a/homeassistant/components/ads/switch.py b/homeassistant/components/ads/switch.py index 0412a127c95..2506757e9d2 100644 --- a/homeassistant/components/ads/switch.py +++ b/homeassistant/components/ads/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/valve.py b/homeassistant/components/ads/valve.py index b94215ec9ea..a251e14b3c3 100644 --- a/homeassistant/components/ads/valve.py +++ b/homeassistant/components/ads/valve.py @@ -14,7 +14,7 @@ from homeassistant.components.valve import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/aftership/const.py b/homeassistant/components/aftership/const.py index 385570e145f..c5d7b00a942 100644 --- a/homeassistant/components/aftership/const.py +++ b/homeassistant/components/aftership/const.py @@ -7,7 +7,7 @@ from typing import Final import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv DOMAIN: Final = "aftership" diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index c019634197d..085be2499d4 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -9,7 +9,7 @@ from pyaftership import AfterShip, AfterShipException from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index 2811156ac90..de60ef84efa 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -13,8 +13,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index d0ab16e9758..7cd113125a8 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -18,8 +18,8 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 80a676a40fa..fde4638e179 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index 72b1084d072..6779eada070 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -23,8 +23,7 @@ from homeassistant.const import ( SERVICE_ALARM_TRIGGER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index cf72133ea12..d7092bbe1c4 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -12,8 +12,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +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 diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 12341c158c0..b6ce87941f6 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index c5b4ad15904..e70055c20b1 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -50,8 +50,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, State -import homeassistant.util.color as color_util -import homeassistant.util.dt as dt_util +from homeassistant.util import color as color_util, dt as dt_util from .const import ( API_TEMP_UNITS, diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index 0d75ee04b7a..a37a95e59d5 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import template from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( API_PASSWORD, diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 03b6a22007c..20e3ef1d7c7 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -24,7 +24,7 @@ from homeassistant.core import ( ) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.significant_change import create_checker -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType, json_loads_object from .const import ( diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 506cb41659a..48d3ae6f526 100644 --- a/homeassistant/components/alpha_vantage/sensor.py +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_CURRENCY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 62852848a9c..985b3b6dd7c 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -22,7 +22,7 @@ from homeassistant.generated.amazon_polly import ( SUPPORTED_REGIONS, SUPPORTED_VOICES, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 469ad7e6e06..374c313a144 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -17,9 +17,8 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send -import homeassistant.helpers.entity_registry as er from .const import ( ATTR_LAST_DATA, diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 624e0145b86..313d3263932 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -37,8 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import Unauthorized, UnknownUser -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import async_extract_entity_ids diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index 05581df6371..ce2830d5b14 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -14,8 +14,8 @@ from homeassistant.components.air_quality import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 9bcddcb868f..0df3b8138e2 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import Event, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index b63475c80a4..9339e2986e5 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -11,6 +11,7 @@ import uuid import aiohttp +from homeassistant import config as conf_util from homeassistant.components import hassio from homeassistant.components.api import ATTR_INSTALLATION_TYPE from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN @@ -22,13 +23,12 @@ from homeassistant.components.recorder import ( DOMAIN as RECORDER_DOMAIN, get_instance as get_recorder_instance, ) -import homeassistant.config as conf_util from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION 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 -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.storage import Store from homeassistant.helpers.system_info import async_get_system_info diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 6b27a61e065..97691c8b028 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py index 400ac6d5899..fe9c6513041 100644 --- a/homeassistant/components/anthemav/config_flow.py +++ b/homeassistant/components/anthemav/config_flow.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import DEFAULT_NAME, DEFAULT_PORT, DEVICE_TIMEOUT_SECONDS, DOMAIN diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py index 68d3f58a63a..40f71ec4e4b 100644 --- a/homeassistant/components/apache_kafka/__init__.py +++ b/homeassistant/components/apache_kafka/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter from homeassistant.helpers.typing import ConfigType from homeassistant.util import ssl as ssl_util diff --git a/homeassistant/components/apcupsd/config_flow.py b/homeassistant/components/apcupsd/config_flow.py index 00f757a1fd7..b65c9c33265 100644 --- a/homeassistant/components/apcupsd/config_flow.py +++ b/homeassistant/components/apcupsd/config_flow.py @@ -10,8 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from .const import CONNECTION_TIMEOUT, DOMAIN from .coordinator import APCUPSdData diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index ba71fb0def1..d183d46a717 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -11,6 +11,7 @@ from aiohttp import web from aiohttp.web_exceptions import HTTPBadRequest import voluptuous as vol +from homeassistant import core as ha from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.components.http import ( @@ -36,7 +37,6 @@ from homeassistant.const import ( URL_API_STREAM, URL_API_TEMPLATE, ) -import homeassistant.core as ha from homeassistant.core import Event, EventStateChangedData, HomeAssistant from homeassistant.exceptions import ( InvalidEntityFormatError, diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index c6b71c64b4f..8a2336eea3b 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -40,7 +40,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import AppleTvConfigEntry, AppleTVManager from .browse_media import build_app_list diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 0ee936aeef2..68f10df7886 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -26,8 +26,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection, config_entry_oauth2_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + collection, + config_entry_oauth2_flow, + config_validation as cv, +) from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import ( diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py index eb4e21c127f..a2efcb577d3 100644 --- a/homeassistant/components/apprise/notify.py +++ b/homeassistant/components/apprise/notify.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/aprilaire/config_flow.py b/homeassistant/components/aprilaire/config_flow.py index f6c33f75e53..0b4f9af3401 100644 --- a/homeassistant/components/aprilaire/config_flow.py +++ b/homeassistant/components/aprilaire/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py index 6b132cfcc95..a5126eda95e 100644 --- a/homeassistant/components/aprilaire/coordinator.py +++ b/homeassistant/components/aprilaire/coordinator.py @@ -11,7 +11,7 @@ from pyaprilaire.const import MODELS, Attribute, FunctionalDomain from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index fc23fc5e436..fc3dbcabfe8 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -26,7 +26,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify diff --git a/homeassistant/components/apsystems/config_flow.py b/homeassistant/components/apsystems/config_flow.py index 5f2f1393aa0..9be0b5f4cf7 100644 --- a/homeassistant/components/apsystems/config_flow.py +++ b/homeassistant/components/apsystems/config_flow.py @@ -8,8 +8,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DEFAULT_PORT, DOMAIN diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 9c2ee9957af..e0cae5df162 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index ed0cc463263..667842a020c 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 343cb6492da..90660028b83 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/arest/binary_sensor.py b/homeassistant/components/arest/binary_sensor.py index 00d4d6bbf9b..a99ef049543 100644 --- a/homeassistant/components/arest/binary_sensor.py +++ b/homeassistant/components/arest/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_PIN, CONF_RESOURCE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 8c68c13018b..6554704b230 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index bcdba36cb58..7539336c38b 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_NAME, CONF_RESOURCE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index c3650587690..828528508ec 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -13,8 +13,8 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType DEFAULT_HOST = "192.168.178.1" diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index 911fab441e5..c2f0d44a6f8 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -16,7 +16,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/assist_pipeline/logbook.py b/homeassistant/components/assist_pipeline/logbook.py index 50c5176bb22..b7ab24d2f2f 100644 --- a/homeassistant/components/assist_pipeline/logbook.py +++ b/homeassistant/components/assist_pipeline/logbook.py @@ -7,7 +7,7 @@ from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import DOMAIN, EVENT_RECORDING diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 9353bbe0007..1d320d79bf2 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1101,11 +1101,10 @@ class PipelineRun: "speech", "" ) chat_session.async_add_message( - conversation.ChatMessage( + conversation.Content( role="assistant", agent_id=agent_id, content=speech, - native=intent_response, ) ) conversation_result = conversation.ConversationResult( @@ -1123,6 +1122,7 @@ class PipelineRun: context=user_input.context, language=user_input.language, agent_id=user_input.agent_id, + extra_system_prompt=user_input.extra_system_prompt, ) speech = conversation_result.response.speech.get("plain", {}).get( "speech", "" diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 47b0123a244..038ff517264 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -63,6 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + component.async_register_entity_service( + "start_conversation", + vol.All( + cv.make_entity_service_schema( + { + vol.Optional("start_message"): str, + vol.Optional("start_media_id"): str, + vol.Optional("extra_system_prompt"): str, + } + ), + cv.has_at_least_one_key("start_message", "start_media_id"), + ), + "async_internal_start_conversation", + [AssistSatelliteEntityFeature.START_CONVERSATION], + ) hass.data[CONNECTION_TEST_DATA] = {} async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index 61ac7ecb39d..f7ac7e524b4 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag): ANNOUNCE = 1 """Device supports remotely triggered announcements.""" + + START_CONVERSATION = 2 + """Device supports starting conversations.""" diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index e9a5d22c0d0..927229c9756 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -10,7 +10,7 @@ import logging import time from typing import Any, Final, Literal, final -from homeassistant.components import media_source, stt, tts +from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.assist_pipeline import ( OPTION_PREFERRED, AudioSettings, @@ -27,6 +27,7 @@ from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) from homeassistant.core import Context, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity from homeassistant.helpers.entity import EntityDescription @@ -117,6 +118,7 @@ class AssistSatelliteEntity(entity.Entity): _run_has_tts: bool = False _is_announcing = False + _extra_system_prompt: str | None = None _wake_word_intercept_future: asyncio.Future[str | None] | None = None _attr_tts_options: dict[str, Any] | None = None _pipeline_task: asyncio.Task | None = None @@ -216,6 +218,60 @@ class AssistSatelliteEntity(entity.Entity): """ raise NotImplementedError + async def async_internal_start_conversation( + self, + start_message: str | None = None, + start_media_id: str | None = None, + extra_system_prompt: str | None = None, + ) -> None: + """Start a conversation from the satellite. + + If start_media_id is not provided, message is synthesized to + audio with the selected pipeline. + + If start_media_id is provided, it is played directly. It is possible + to omit the message and the satellite will not show any text. + + Calls async_start_conversation. + """ + await self._cancel_running_pipeline() + + # The Home Assistant built-in agent doesn't support conversations. + pipeline = async_get_pipeline(self.hass, self._resolve_pipeline()) + if pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT: + raise HomeAssistantError( + "Built-in conversation agent does not support starting conversations" + ) + + if start_message is None: + start_message = "" + + announcement = await self._resolve_announcement_media_id( + start_message, start_media_id + ) + + if self._is_announcing: + raise SatelliteBusyError + + self._is_announcing = True + # Provide our start info to the LLM so it understands context of incoming message + if extra_system_prompt is not None: + self._extra_system_prompt = extra_system_prompt + else: + self._extra_system_prompt = start_message or None + + try: + await self.async_start_conversation(announcement) + finally: + self._is_announcing = False + self._extra_system_prompt = None + + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + raise NotImplementedError + async def async_accept_pipeline_from_satellite( self, audio_stream: AsyncIterable[bytes], @@ -302,6 +358,7 @@ class AssistSatelliteEntity(entity.Entity): ), start_stage=start_stage, end_stage=end_stage, + conversation_extra_system_prompt=self._extra_system_prompt, ), f"{self.entity_id}_pipeline", ) diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json index a98c3aefc5b..1ed29541621 100644 --- a/homeassistant/components/assist_satellite/icons.json +++ b/homeassistant/components/assist_satellite/icons.json @@ -7,6 +7,9 @@ "services": { "announce": { "service": "mdi:bullhorn" + }, + "start_conversation": { + "service": "mdi:forum" } } } diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index e7fefc4705f..89a20ada6f3 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,3 +14,23 @@ announce: required: false selector: text: +start_conversation: + target: + entity: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + fields: + start_message: + required: false + example: "You left the lights on in the living room. Turn them off?" + selector: + text: + start_media_id: + required: false + selector: + text: + extra_system_prompt: + required: false + selector: + text: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 7f1426ef529..e83f4666b5d 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -25,6 +25,24 @@ "description": "The media ID to announce instead of using text-to-speech." } } + }, + "start_conversation": { + "name": "Start Conversation", + "description": "Start a conversation from a satellite.", + "fields": { + "start_message": { + "name": "Message", + "description": "The message to start with." + }, + "start_media_id": { + "name": "Media ID", + "description": "The media ID to start with instead of using text-to-speech." + }, + "extra_system_prompt": { + "name": "Extra system prompt", + "description": "Provide background information to the AI about the request." + } + } } } } diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py index 39b18089284..30afab16011 100644 --- a/homeassistant/components/aten_pe/switch.py +++ b/homeassistant/components/aten_pe/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py index fd8250e899f..a1254c1ff49 100644 --- a/homeassistant/components/atome/sensor.py +++ b/homeassistant/components/atome/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index fe5d90371ad..c681cc98808 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import AugustConfigEntry, AugustData from .entity import AugustEntity diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index c9efb081a01..6c85f5b7f55 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -12,7 +12,7 @@ from homeassistant import data_entry_flow from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowContext -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey WS_TYPE_SETUP_MFA = "auth/setup_mfa" diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 4e6b098ef1e..856060f8c75 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -48,8 +48,7 @@ from homeassistant.core import ( valid_entity_id, ) from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError -from homeassistant.helpers import condition -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.issue_registry import ( diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py index 48471b41633..ec39a6f371c 100644 --- a/homeassistant/components/avea/light.py +++ b/homeassistant/components/avea/light.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util def setup_platform( diff --git a/homeassistant/components/avion/light.py b/homeassistant/components/avion/light.py index 687405e3064..5b9371e0e2b 100644 --- a/homeassistant/components/avion/light.py +++ b/homeassistant/components/avion/light.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index bc9d34e728e..abe6cdfe15f 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import MATCH_ALL from homeassistant.core import Event, HomeAssistant, State from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter from homeassistant.helpers.event import async_call_later from homeassistant.helpers.json import JSONEncoder diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py index a0aa36804c3..83eb8076fef 100644 --- a/homeassistant/components/azure_service_bus/notify.py +++ b/homeassistant/components/azure_service_bus/notify.py @@ -23,7 +23,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType CONF_CONNECTION_STRING = "connection_string" diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 10294f6ff12..3003f94c2ed 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -26,10 +26,12 @@ from .manager import ( BackupReaderWriterError, CoreBackupReaderWriter, CreateBackupEvent, + IdleEvent, IncorrectPasswordError, ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder @@ -47,12 +49,15 @@ __all__ = [ "BackupReaderWriterError", "CreateBackupEvent", "Folder", + "IdleEvent", "IncorrectPasswordError", "LocalBackupAgent", "ManagerBackup", "NewBackup", "RestoreBackupEvent", + "RestoreBackupState", "WrittenBackup", + "async_get_manager", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index cb03327e941..297ccd6f685 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -10,31 +10,40 @@ from typing import Any, Protocol from propcache.api import cached_property from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from .models import AgentBackup +from .models import AgentBackup, BackupError -class BackupAgentError(HomeAssistantError): +class BackupAgentError(BackupError): """Base class for backup agent errors.""" + error_code = "backup_agent_error" + class BackupAgentUnreachableError(BackupAgentError): """Raised when the agent can't reach its API.""" + error_code = "backup_agent_unreachable" _message = "The backup agent is unreachable." +class BackupNotFound(BackupAgentError): + """Raised when a backup is not found.""" + + error_code = "backup_not_found" + + class BackupAgent(abc.ABC): """Backup agent interface.""" domain: str name: str + unique_id: str @cached_property def agent_id(self) -> str: """Return the agent_id.""" - return f"{self.domain}.{self.name}" + return f"{self.domain}.{self.unique_id}" @abc.abstractmethod async def async_download_backup( @@ -91,11 +100,16 @@ class LocalBackupAgent(BackupAgent): @abc.abstractmethod def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup. + """Return the local path to an existing backup. The method should return the path to the backup file with the specified id. + Raises BackupAgentError if the backup does not exist. """ + @abc.abstractmethod + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + class BackupAgentPlatformProtocol(Protocol): """Define the format of backup platforms which implement backup agents.""" diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index ef4924161c2..c76b50b5935 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,7 +11,7 @@ from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.hassio import is_hassio -from .agent import BackupAgent, LocalBackupAgent +from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .const import DOMAIN, LOGGER from .models import AgentBackup from .util import read_backup @@ -32,13 +32,14 @@ class CoreLocalBackupAgent(LocalBackupAgent): domain = DOMAIN name = "local" + unique_id = "local" def __init__(self, hass: HomeAssistant) -> None: """Initialize the backup agent.""" super().__init__() self._hass = hass self._backup_dir = Path(hass.config.path("backups")) - self._backups: dict[str, AgentBackup] = {} + self._backups: dict[str, tuple[AgentBackup, Path]] = {} self._loaded_backups = False async def _load_backups(self) -> None: @@ -48,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): self._backups = backups self._loaded_backups = True - def _read_backups(self) -> dict[str, AgentBackup]: + def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]: """Read backups from disk.""" - backups: dict[str, AgentBackup] = {} + backups: dict[str, tuple[AgentBackup, Path]] = {} for backup_path in self._backup_dir.glob("*.tar"): try: backup = read_backup(backup_path) - backups[backup.backup_id] = backup + backups[backup.backup_id] = (backup, backup_path) except (OSError, TarError, json.JSONDecodeError, KeyError) as err: LOGGER.warning("Unable to read backup %s: %s", backup_path, err) return backups @@ -75,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): **kwargs: Any, ) -> None: """Upload a backup.""" - self._backups[backup.backup_id] = backup + self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup)) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" if not self._loaded_backups: await self._load_backups() - return list(self._backups.values()) + return [backup for backup, _ in self._backups.values()] async def async_get_backup( self, @@ -92,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent): if not self._loaded_backups: await self._load_backups() - if not (backup := self._backups.get(backup_id)): + if backup_id not in self._backups: return None - backup_path = self.get_backup_path(backup_id) + backup, backup_path = self._backups[backup_id] if not await self._hass.async_add_executor_job(backup_path.exists): LOGGER.debug( ( @@ -111,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent): return backup def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" - return self._backup_dir / f"{backup_id}.tar" + """Return the local path to an existing backup. + + Raises BackupAgentError if the backup does not exist. + """ + try: + return self._backups[backup_id][1] + except KeyError as err: + raise BackupNotFound(f"Backup {backup_id} does not exist") from err + + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + return self._backup_dir / f"{backup.backup_id}.tar" async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" - if await self.async_get_backup(backup_id) is None: - return + if not self._loaded_backups: + await self._load_backups() - backup_path = self.get_backup_path(backup_id) + try: + backup_path = self.get_backup_path(backup_id) + except BackupNotFound: + return await self._hass.async_add_executor_job(backup_path.unlink, True) LOGGER.debug("Deleted backup located at %s", backup_path) self._backups.pop(backup_id) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 1d1b8046360..0baefe1f52d 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -40,6 +40,7 @@ BACKUP_START_TIME_JITTER = 60 * 60 class StoredBackupConfig(TypedDict): """Represent the stored backup config.""" + agents: dict[str, StoredAgentConfig] create_backup: StoredCreateBackupConfig last_attempted_automatic_backup: str | None last_completed_automatic_backup: str | None @@ -51,6 +52,7 @@ class StoredBackupConfig(TypedDict): class BackupConfigData: """Represent loaded backup config data.""" + agents: dict[str, AgentConfig] create_backup: CreateBackupConfig last_attempted_automatic_backup: datetime | None = None last_completed_automatic_backup: datetime | None = None @@ -84,6 +86,10 @@ class BackupConfigData: days = [Day(day) for day in data["schedule"]["days"]] return cls( + agents={ + agent_id: AgentConfig(protected=agent_data["protected"]) + for agent_id, agent_data in data["agents"].items() + }, create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], include_addons=data["create_backup"]["include_addons"], @@ -120,6 +126,9 @@ class BackupConfigData: last_completed = None return StoredBackupConfig( + agents={ + agent_id: agent.to_dict() for agent_id, agent in self.agents.items() + }, create_backup=self.create_backup.to_dict(), last_attempted_automatic_backup=last_attempted, last_completed_automatic_backup=last_completed, @@ -134,6 +143,7 @@ class BackupConfig: def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None: """Initialize backup config.""" self.data = BackupConfigData( + agents={}, create_backup=CreateBackupConfig(), retention=RetentionConfig(), schedule=BackupSchedule(), @@ -149,11 +159,20 @@ class BackupConfig: async def update( self, *, + agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED, create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, retention: RetentionParametersDict | UndefinedType = UNDEFINED, schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, ) -> None: """Update config.""" + if agents is not UNDEFINED: + for agent_id, agent_config in agents.items(): + if agent_id not in self.data.agents: + self.data.agents[agent_id] = AgentConfig(**agent_config) + else: + self.data.agents[agent_id] = replace( + self.data.agents[agent_id], **agent_config + ) if create_backup is not UNDEFINED: self.data.create_backup = replace(self.data.create_backup, **create_backup) if retention is not UNDEFINED: @@ -170,6 +189,31 @@ class BackupConfig: self._manager.store.save() +@dataclass(kw_only=True) +class AgentConfig: + """Represent the config for an agent.""" + + protected: bool + + def to_dict(self) -> StoredAgentConfig: + """Convert agent config to a dict.""" + return { + "protected": self.protected, + } + + +class StoredAgentConfig(TypedDict): + """Represent the stored config for an agent.""" + + protected: bool + + +class AgentParametersDict(TypedDict, total=False): + """Represent the parameters for an agent.""" + + protected: bool + + @dataclass(kw_only=True) class RetentionConfig: """Represent the backup retention configuration.""" diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index b909b2728a7..6b06db4601d 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -69,7 +69,7 @@ class DownloadBackupView(HomeAssistantView): CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" } - if not password: + if not password or not backup.protected: return await self._send_backup_no_password( request, headers, backup_id, agent_id, agent, manager ) @@ -123,13 +123,13 @@ class DownloadBackupView(HomeAssistantView): worker_done_event = asyncio.Event() - def on_done() -> None: + def on_done(error: Exception | None) -> None: """Call by the worker thread when it's done.""" hass.loop.call_soon_threadsafe(worker_done_event.set) stream = util.AsyncIteratorWriter(hass) worker = threading.Thread( - target=util.decrypt_backup, args=[reader, stream, password, on_done] + target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []] ) try: worker.start() @@ -144,13 +144,17 @@ class DownloadBackupView(HomeAssistantView): class UploadBackupView(HomeAssistantView): - """Generate backup view.""" + """Upload backup view.""" url = "/api/backup/upload" name = "api:backup:upload" @require_admin async def post(self, request: Request) -> Response: + """Upload a backup file.""" + return await self._post(request) + + async def _post(self, request: Request) -> Response: """Upload a backup file.""" try: agent_ids = request.query.getall("agent_id") @@ -161,7 +165,9 @@ class UploadBackupView(HomeAssistantView): contents = cast(BodyPartReader, await reader.next()) try: - await manager.async_receive_backup(contents=contents, agent_ids=agent_ids) + backup_id = await manager.async_receive_backup( + contents=contents, agent_ids=agent_ids + ) except OSError as err: return Response( body=f"Can't write backup file: {err}", @@ -175,4 +181,4 @@ class UploadBackupView(HomeAssistantView): except asyncio.CancelledError: return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) - return Response(status=HTTPStatus.CREATED) + return self.json({"backup_id": backup_id}, status_code=HTTPStatus.CREATED) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 8c8cd805565..1dbd8f8547d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -5,7 +5,7 @@ from __future__ import annotations import abc import asyncio from collections.abc import AsyncIterator, Callable, Coroutine -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import StrEnum import hashlib import io @@ -19,17 +19,20 @@ from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast import aiohttp from securetar import SecureTarFile, atomic_contents_add -from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key +from homeassistant.backup_restore import ( + RESTORE_BACKUP_FILE, + RESTORE_BACKUP_RESULT_FILE, + password_to_key, +) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( instance_id, integration_platform, issue_registry as ir, ) from homeassistant.helpers.json import json_bytes -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, json as json_util from . import util as backup_util from .agent import ( @@ -47,10 +50,12 @@ from .const import ( EXCLUDE_FROM_BACKUP, LOGGER, ) -from .models import AgentBackup, BackupManagerError, Folder +from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder from .store import BackupStore from .util import ( AsyncIteratorReader, + DecryptedBackupStreamer, + EncryptedBackupStreamer, make_backup_dir, read_backup, validate_password, @@ -66,10 +71,18 @@ class NewBackup: @dataclass(frozen=True, kw_only=True, slots=True) -class ManagerBackup(AgentBackup): +class AgentBackupStatus: + """Agent specific backup attributes.""" + + protected: bool + size: int + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ManagerBackup(BaseBackup): """Backup class.""" - agent_ids: list[str] + agents: dict[str, AgentBackupStatus] failed_agent_ids: list[str] with_automatic_settings: bool | None @@ -171,6 +184,7 @@ class CreateBackupEvent(ManagerStateEvent): """Backup in progress.""" manager_state: BackupManagerState = BackupManagerState.CREATE_BACKUP + reason: str | None stage: CreateBackupStage | None state: CreateBackupState @@ -180,6 +194,7 @@ class ReceiveBackupEvent(ManagerStateEvent): """Backup receive.""" manager_state: BackupManagerState = BackupManagerState.RECEIVE_BACKUP + reason: str | None stage: ReceiveBackupStage | None state: ReceiveBackupState @@ -189,6 +204,7 @@ class RestoreBackupEvent(ManagerStateEvent): """Backup restore.""" manager_state: BackupManagerState = BackupManagerState.RESTORE_BACKUP + reason: str | None stage: RestoreBackupStage | None state: RestoreBackupState @@ -249,20 +265,32 @@ class BackupReaderWriter(abc.ABC): ) -> None: """Restore a backup.""" + @abc.abstractmethod + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Get restore events after core restart.""" -class BackupReaderWriterError(HomeAssistantError): + +class BackupReaderWriterError(BackupError): """Backup reader/writer error.""" + error_code = "backup_reader_writer_error" + class IncorrectPasswordError(BackupReaderWriterError): """Raised when the password is incorrect.""" + error_code = "password_incorrect" _message = "The password provided is incorrect." class DecryptOnDowloadNotSupported(BackupManagerError): """Raised when on-the-fly decryption is not supported.""" + error_code = "decrypt_on_download_not_supported" _message = "On-the-fly decryption is not supported for this backup." @@ -292,6 +320,7 @@ class BackupManager: # Latest backup event and backup event subscribers self.last_event: ManagerStateEvent = IdleEvent() + self.last_non_idle_event: ManagerStateEvent | None = None self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = [] async def async_setup(self) -> None: @@ -301,6 +330,10 @@ class BackupManager: self.config.load(stored["config"]) self.known_backups.load(stored["backups"]) + await self._reader_writer.async_resume_restore_progress_after_restart( + on_progress=self.async_on_backup_event + ) + await self.load_platforms() @property @@ -430,20 +463,61 @@ class BackupManager: backup: AgentBackup, agent_ids: list[str], open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, ) -> dict[str, Exception]: """Upload a backup to selected agents.""" agent_errors: dict[str, Exception] = {} LOGGER.debug("Uploading backup %s to agents %s", backup.backup_id, agent_ids) - sync_backup_results = await asyncio.gather( - *( - self.backup_agents[agent_id].async_upload_backup( - open_stream=open_stream, - backup=backup, + async def upload_backup_to_agent(agent_id: str) -> None: + """Upload backup to a single agent, and encrypt or decrypt as needed.""" + config = self.config.data.agents.get(agent_id) + should_encrypt = config.protected if config else password is not None + streamer: DecryptedBackupStreamer | EncryptedBackupStreamer | None = None + if should_encrypt == backup.protected or password is None: + # The backup we're uploading is already in the correct state, or we + # don't have a password to encrypt or decrypt it + LOGGER.debug( + "Uploading backup %s to agent %s as is", backup.backup_id, agent_id ) - for agent_id in agent_ids - ), + open_stream_func = open_stream + _backup = backup + elif should_encrypt: + # The backup we're uploading is not encrypted, but the agent requires it + LOGGER.debug( + "Uploading encrypted backup %s to agent %s", + backup.backup_id, + agent_id, + ) + streamer = EncryptedBackupStreamer( + self.hass, backup, open_stream, password + ) + else: + # The backup we're uploading is encrypted, but the agent requires it + # decrypted + LOGGER.debug( + "Uploading decrypted backup %s to agent %s", + backup.backup_id, + agent_id, + ) + streamer = DecryptedBackupStreamer( + self.hass, backup, open_stream, password + ) + if streamer: + open_stream_func = streamer.open_stream + _backup = replace( + backup, protected=should_encrypt, size=streamer.size() + ) + await self.backup_agents[agent_id].async_upload_backup( + open_stream=open_stream_func, + backup=_backup, + ) + if streamer: + await streamer.wait() + + sync_backup_results = await asyncio.gather( + *(upload_backup_to_agent(agent_id) for agent_id in agent_ids), return_exceptions=True, ) for idx, result in enumerate(sync_backup_results): @@ -499,7 +573,7 @@ class BackupManager: agent_backup, await instance_id.async_get(self.hass) ) backups[backup_id] = ManagerBackup( - agent_ids=[], + agents={}, addons=agent_backup.addons, backup_id=backup_id, date=agent_backup.date, @@ -510,11 +584,12 @@ class BackupManager: homeassistant_included=agent_backup.homeassistant_included, homeassistant_version=agent_backup.homeassistant_version, name=agent_backup.name, - protected=agent_backup.protected, - size=agent_backup.size, with_automatic_settings=with_automatic_settings, ) - backups[backup_id].agent_ids.append(agent_ids[idx]) + backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus( + protected=agent_backup.protected, + size=agent_backup.size, + ) return (backups, agent_errors) @@ -550,7 +625,7 @@ class BackupManager: result, await instance_id.async_get(self.hass) ) backup = ManagerBackup( - agent_ids=[], + agents={}, addons=result.addons, backup_id=result.backup_id, date=result.date, @@ -561,11 +636,12 @@ class BackupManager: homeassistant_included=result.homeassistant_included, homeassistant_version=result.homeassistant_version, name=result.name, - protected=result.protected, - size=result.size, with_automatic_settings=with_automatic_settings, ) - backup.agent_ids.append(agent_ids[idx]) + backup.agents[agent_ids[idx]] = AgentBackupStatus( + protected=result.protected, + size=result.size, + ) return (backup, agent_errors) @@ -614,24 +690,39 @@ class BackupManager: *, agent_ids: list[str], contents: aiohttp.BodyPartReader, - ) -> None: + ) -> str: """Receive and store a backup file from upload.""" if self.state is not BackupManagerState.IDLE: raise BackupManagerError(f"Backup manager busy: {self.state}") self.async_on_backup_event( - ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS) + ReceiveBackupEvent( + reason=None, + stage=None, + state=ReceiveBackupState.IN_PROGRESS, + ) ) try: - await self._async_receive_backup(agent_ids=agent_ids, contents=contents) + backup_id = await self._async_receive_backup( + agent_ids=agent_ids, contents=contents + ) except Exception: self.async_on_backup_event( - ReceiveBackupEvent(stage=None, state=ReceiveBackupState.FAILED) + ReceiveBackupEvent( + reason="unknown_error", + stage=None, + state=ReceiveBackupState.FAILED, + ) ) raise else: self.async_on_backup_event( - ReceiveBackupEvent(stage=None, state=ReceiveBackupState.COMPLETED) + ReceiveBackupEvent( + reason=None, + stage=None, + state=ReceiveBackupState.COMPLETED, + ) ) + return backup_id finally: self.async_on_backup_event(IdleEvent()) @@ -640,11 +731,12 @@ class BackupManager: *, agent_ids: list[str], contents: aiohttp.BodyPartReader, - ) -> None: + ) -> str: """Receive and store a backup file from upload.""" contents.chunk_size = BUF_SIZE self.async_on_backup_event( ReceiveBackupEvent( + reason=None, stage=ReceiveBackupStage.RECEIVE_FILE, state=ReceiveBackupState.IN_PROGRESS, ) @@ -656,6 +748,7 @@ class BackupManager: ) self.async_on_backup_event( ReceiveBackupEvent( + reason=None, stage=ReceiveBackupStage.UPLOAD_TO_AGENTS, state=ReceiveBackupState.IN_PROGRESS, ) @@ -664,14 +757,19 @@ class BackupManager: backup=written_backup.backup, agent_ids=agent_ids, open_stream=written_backup.open_stream, + # When receiving a backup, we don't decrypt or encrypt it according to the + # agent settings, we just upload it as is. + password=None, ) await written_backup.release_stream() self.known_backups.add(written_backup.backup, agent_errors) + return written_backup.backup.backup_id async def async_create_backup( self, *, agent_ids: list[str], + extra_metadata: dict[str, bool | str] | None = None, include_addons: list[str] | None, include_all_addons: bool, include_database: bool, @@ -684,6 +782,7 @@ class BackupManager: """Create a backup.""" new_backup = await self.async_initiate_backup( agent_ids=agent_ids, + extra_metadata=extra_metadata, include_addons=include_addons, include_all_addons=include_all_addons, include_database=include_database, @@ -717,6 +816,7 @@ class BackupManager: self, *, agent_ids: list[str], + extra_metadata: dict[str, bool | str] | None = None, include_addons: list[str] | None, include_all_addons: bool, include_database: bool, @@ -736,11 +836,16 @@ class BackupManager: self.store.save() self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) + CreateBackupEvent( + reason=None, + stage=None, + state=CreateBackupState.IN_PROGRESS, + ) ) try: return await self._async_create_backup( agent_ids=agent_ids, + extra_metadata=extra_metadata, include_addons=include_addons, include_all_addons=include_all_addons, include_database=include_database, @@ -751,9 +856,14 @@ class BackupManager: raise_task_error=raise_task_error, with_automatic_settings=with_automatic_settings, ) - except Exception: + except Exception as err: + reason = err.error_code if isinstance(err, BackupError) else "unknown_error" self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + CreateBackupEvent( + reason=reason, + stage=None, + state=CreateBackupState.FAILED, + ) ) self.async_on_backup_event(IdleEvent()) if with_automatic_settings: @@ -764,6 +874,7 @@ class BackupManager: self, *, agent_ids: list[str], + extra_metadata: dict[str, bool | str] | None, include_addons: list[str] | None, include_all_addons: bool, include_database: bool, @@ -790,6 +901,7 @@ class BackupManager: name or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" ) + extra_metadata = extra_metadata or {} try: ( @@ -798,7 +910,8 @@ class BackupManager: ) = await self._reader_writer.async_create_backup( agent_ids=agent_ids, backup_name=backup_name, - extra_metadata={ + extra_metadata=extra_metadata + | { "instance_id": await instance_id.async_get(self.hass), "with_automatic_settings": with_automatic_settings, }, @@ -814,7 +927,7 @@ class BackupManager: raise BackupManagerError(str(err)) from err backup_finish_task = self._backup_finish_task = self.hass.async_create_task( - self._async_finish_backup(agent_ids, with_automatic_settings), + self._async_finish_backup(agent_ids, with_automatic_settings, password), name="backup_manager_finish_backup", ) if not raise_task_error: @@ -831,7 +944,7 @@ class BackupManager: return new_backup async def _async_finish_backup( - self, agent_ids: list[str], with_automatic_settings: bool + self, agent_ids: list[str], with_automatic_settings: bool, password: str | None ) -> None: """Finish a backup.""" if TYPE_CHECKING: @@ -854,6 +967,7 @@ class BackupManager: ) self.async_on_backup_event( CreateBackupEvent( + reason=None, stage=CreateBackupStage.UPLOAD_TO_AGENTS, state=CreateBackupState.IN_PROGRESS, ) @@ -864,6 +978,7 @@ class BackupManager: backup=written_backup.backup, agent_ids=agent_ids, open_stream=written_backup.open_stream, + password=password, ) finally: await written_backup.release_stream() @@ -884,14 +999,22 @@ class BackupManager: finally: self._backup_task = None self._backup_finish_task = None - self.async_on_backup_event( - CreateBackupEvent( - stage=None, - state=CreateBackupState.COMPLETED - if backup_success - else CreateBackupState.FAILED, + if backup_success: + self.async_on_backup_event( + CreateBackupEvent( + reason=None, + stage=None, + state=CreateBackupState.COMPLETED, + ) + ) + else: + self.async_on_backup_event( + CreateBackupEvent( + reason="upload_failed", + stage=None, + state=CreateBackupState.FAILED, + ) ) - ) self.async_on_backup_event(IdleEvent()) async def async_restore_backup( @@ -910,7 +1033,11 @@ class BackupManager: raise BackupManagerError(f"Backup manager busy: {self.state}") self.async_on_backup_event( - RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS) + RestoreBackupEvent( + reason=None, + stage=None, + state=RestoreBackupState.IN_PROGRESS, + ) ) try: await self._async_restore_backup( @@ -923,11 +1050,28 @@ class BackupManager: restore_homeassistant=restore_homeassistant, ) self.async_on_backup_event( - RestoreBackupEvent(stage=None, state=RestoreBackupState.COMPLETED) + RestoreBackupEvent( + reason=None, + stage=None, + state=RestoreBackupState.COMPLETED, + ) ) + except BackupError as err: + self.async_on_backup_event( + RestoreBackupEvent( + reason=err.error_code, + stage=None, + state=RestoreBackupState.FAILED, + ) + ) + raise except Exception: self.async_on_backup_event( - RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED) + RestoreBackupEvent( + reason="unknown_error", + stage=None, + state=RestoreBackupState.FAILED, + ) ) raise finally: @@ -975,6 +1119,8 @@ class BackupManager: if (current_state := self.state) != (new_state := event.manager_state): LOGGER.debug("Backup state: %s -> %s", current_state, new_state) self.last_event = event + if not isinstance(event, IdleEvent): + self.last_non_idle_event = event for subscription in self._backup_event_subscriptions: subscription(event) @@ -1020,7 +1166,11 @@ class BackupManager: learn_more_url="homeassistant://config/backup", severity=ir.IssueSeverity.WARNING, translation_key="automatic_backup_failed_upload_agents", - translation_placeholders={"failed_agents": ", ".join(agent_errors)}, + translation_placeholders={ + "failed_agents": ", ".join( + self.backup_agents[agent_id].name for agent_id in agent_errors + ) + }, ) async def async_can_decrypt_on_download( @@ -1048,7 +1198,9 @@ class BackupManager: backup_stream = await agent.async_download_backup(backup_id) reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream)) try: - validate_password_stream(reader, password) + await self.hass.async_add_executor_job( + validate_password_stream, reader, password + ) except backup_util.IncorrectPassword as err: raise IncorrectPasswordError from err except backup_util.UnsupportedSecureTarVersion as err: @@ -1194,13 +1346,32 @@ class CoreBackupReaderWriter(BackupReaderWriter): """Generate a backup.""" manager = self._hass.data[DATA_MANAGER] + agent_config = manager.config.data.agents.get(self._local_agent_id) + if agent_config and not agent_config.protected: + password = None + + backup = AgentBackup( + addons=[], + backup_id=backup_id, + database_included=include_database, + date=date_str, + extra_metadata=extra_metadata, + folders=[], + homeassistant_included=True, + homeassistant_version=HAVERSION, + name=backup_name, + protected=password is not None, + size=0, + ) + local_agent_tar_file_path = None if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - local_agent_tar_file_path = local_agent.get_backup_path(backup_id) + local_agent_tar_file_path = local_agent.get_new_backup_path(backup) on_progress( CreateBackupEvent( + reason=None, stage=CreateBackupStage.HOME_ASSISTANT, state=CreateBackupState.IN_PROGRESS, ) @@ -1238,19 +1409,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): # ValueError from json_bytes raise BackupReaderWriterError(str(err)) from err else: - backup = AgentBackup( - addons=[], - backup_id=backup_id, - database_included=include_database, - date=date_str, - extra_metadata=extra_metadata, - folders=[], - homeassistant_included=True, - homeassistant_version=HAVERSION, - name=backup_name, - protected=password is not None, - size=size_in_bytes, - ) + backup = replace(backup, size=size_in_bytes) async_add_executor_job = self._hass.async_add_executor_job @@ -1364,7 +1523,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): manager = self._hass.data[DATA_MANAGER] if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - tar_file_path = local_agent.get_backup_path(backup.backup_id) + tar_file_path = local_agent.get_new_backup_path(backup) await async_add_executor_job(make_backup_dir, tar_file_path.parent) await async_add_executor_job(shutil.move, temp_file, tar_file_path) else: @@ -1460,10 +1619,62 @@ class CoreBackupReaderWriter(BackupReaderWriter): await self._hass.async_add_executor_job(_write_restore_file) on_progress( - RestoreBackupEvent(stage=None, state=RestoreBackupState.CORE_RESTART) + RestoreBackupEvent( + reason=None, + stage=None, + state=RestoreBackupState.CORE_RESTART, + ) ) await self._hass.services.async_call("homeassistant", "restart", blocking=True) + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Check restore status after core restart.""" + + def _read_restore_file() -> json_util.JsonObjectType | None: + """Read the restore file.""" + result_path = Path(self._hass.config.path(RESTORE_BACKUP_RESULT_FILE)) + + try: + restore_result = json_util.json_loads_object(result_path.read_bytes()) + except FileNotFoundError: + return None + finally: + try: + result_path.unlink(missing_ok=True) + except OSError as err: + LOGGER.warning( + "Unexpected error deleting backup restore result file: %s %s", + type(err), + err, + ) + + return restore_result + + restore_result = await self._hass.async_add_executor_job(_read_restore_file) + if not restore_result: + return + + success = restore_result["success"] + if not success: + LOGGER.warning( + "Backup restore failed with %s: %s", + restore_result["error_type"], + restore_result["error"], + ) + state = RestoreBackupState.COMPLETED if success else RestoreBackupState.FAILED + on_progress( + RestoreBackupEvent( + reason=cast(str, restore_result["error"]), + stage=None, + state=state, + ) + ) + on_progress(IdleEvent()) + def _generate_backup_id(date: str, name: str) -> str: """Generate a backup ID.""" diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index 81c00d699c6..1543d577964 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -28,7 +28,7 @@ class Folder(StrEnum): @dataclass(frozen=True, kw_only=True) -class AgentBackup: +class BaseBackup: """Base backup class.""" addons: list[AddonInfo] @@ -40,12 +40,6 @@ class AgentBackup: homeassistant_included: bool homeassistant_version: str | None # None if homeassistant_included is False name: str - protected: bool - size: int - - def as_dict(self) -> dict: - """Return a dict representation of this backup.""" - return asdict(self) def as_frontend_json(self) -> dict: """Return a dict representation of this backup for sending to frontend.""" @@ -53,6 +47,18 @@ class AgentBackup: key: val for key, val in asdict(self).items() if key != "extra_metadata" } + +@dataclass(frozen=True, kw_only=True) +class AgentBackup(BaseBackup): + """Agent backup class.""" + + protected: bool + size: int + + def as_dict(self) -> dict: + """Return a dict representation of this backup.""" + return asdict(self) + @classmethod def from_dict(cls, data: dict[str, Any]) -> Self: """Create an instance from a JSON serialization.""" @@ -71,5 +77,13 @@ class AgentBackup: ) -class BackupManagerError(HomeAssistantError): +class BackupError(HomeAssistantError): + """Base class for backup errors.""" + + error_code = "unknown" + + +class BackupManagerError(BackupError): """Backup manager error.""" + + error_code = "backup_manager_error" diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 0e1c49426c5..9b4af823c77 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 class StoredBackupData(TypedDict): @@ -47,8 +47,12 @@ class _BackupStore(Store[StoredBackupData]): """Migrate to the new version.""" data = old_data if old_major_version == 1: - if old_minor_version < 2: - # Version 1.2 adds configurable backup time and custom days + if old_minor_version < 3: + # Version 1.2 bumped to 1.3 because 1.2 was changed several + # times during development. + # Version 1.3 adds per agent settings, configurable backup time + # and custom days + data["config"]["agents"] = {} data["config"]["schedule"]["time"] = None if (state := data["config"]["schedule"]["state"]) in ("daily", "never"): data["config"]["schedule"]["days"] = [] diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index e5acf974012..2416aa5f28e 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -3,14 +3,16 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator, Callable, Coroutine import copy +from dataclasses import dataclass, replace from io import BytesIO import json +import os from pathlib import Path, PurePath from queue import SimpleQueue import tarfile -from typing import IO, Self, cast +from typing import IO, Any, Self, cast import aiohttp from securetar import SecureTarError, SecureTarFile, SecureTarReadError @@ -19,6 +21,7 @@ from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.util.json import JsonObjectType, json_loads_object +from homeassistant.util.thread import ThreadWithException from .const import BUF_SIZE, LOGGER from .models import AddonInfo, AgentBackup, Folder @@ -30,6 +33,12 @@ class DecryptError(HomeAssistantError): _message = "Unexpected error during decryption." +class EncryptError(HomeAssistantError): + """Error during encryption.""" + + _message = "Unexpected error during encryption." + + class UnsupportedSecureTarVersion(DecryptError): """Unsupported securetar version.""" @@ -48,6 +57,12 @@ class BackupEmpty(DecryptError): _message = "No tar files found in the backup." +class AbortCipher(HomeAssistantError): + """Abort the cipher operation.""" + + _message = "Abort cipher operation." + + def make_backup_dir(path: Path) -> None: """Create a backup directory if it does not exist.""" path.mkdir(exist_ok=True) @@ -179,6 +194,7 @@ class AsyncIteratorWriter: def __init__(self, hass: HomeAssistant) -> None: """Initialize the wrapper.""" self._hass = hass + self._pos: int = 0 self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1) def __aiter__(self) -> Self: @@ -191,9 +207,14 @@ class AsyncIteratorWriter: return data raise StopAsyncIteration + def tell(self) -> int: + """Return the current position in the iterator.""" + return self._pos + def write(self, s: bytes, /) -> int: """Write data to the iterator.""" asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result() + self._pos += len(s) return len(s) @@ -230,24 +251,37 @@ def decrypt_backup( input_stream: IO[bytes], output_stream: IO[bytes], password: str | None, - on_done: Callable[[], None], + on_done: Callable[[Exception | None], None], + minimum_size: int, + nonces: list[bytes], ) -> None: """Decrypt a backup.""" + error: Exception | None = None try: - with ( - tarfile.open( - fileobj=input_stream, mode="r|", bufsize=BUF_SIZE - ) as input_tar, - tarfile.open( - fileobj=output_stream, mode="w|", bufsize=BUF_SIZE - ) as output_tar, - ): - _decrypt_backup(input_tar, output_tar, password) - except (DecryptError, SecureTarError, tarfile.TarError) as err: - LOGGER.warning("Error decrypting backup: %s", err) + try: + with ( + tarfile.open( + fileobj=input_stream, mode="r|", bufsize=BUF_SIZE + ) as input_tar, + tarfile.open( + fileobj=output_stream, mode="w|", bufsize=BUF_SIZE + ) as output_tar, + ): + _decrypt_backup(input_tar, output_tar, password) + except (DecryptError, SecureTarError, tarfile.TarError) as err: + LOGGER.warning("Error decrypting backup: %s", err) + error = err + else: + # Pad the output stream to the requested minimum size + padding = max(minimum_size - output_stream.tell(), 0) + output_stream.write(b"\0" * padding) + finally: + # Write an empty chunk to signal the end of the stream + output_stream.write(b"") + except AbortCipher: + LOGGER.debug("Cipher operation aborted") finally: - output_stream.write(b"") # Write an empty chunk to signal the end of the stream - on_done() + on_done(error) def _decrypt_backup( @@ -288,6 +322,189 @@ def _decrypt_backup( output_tar.addfile(decrypted_obj, decrypted) +def encrypt_backup( + input_stream: IO[bytes], + output_stream: IO[bytes], + password: str | None, + on_done: Callable[[Exception | None], None], + minimum_size: int, + nonces: list[bytes], +) -> None: + """Encrypt a backup.""" + error: Exception | None = None + try: + try: + with ( + tarfile.open( + fileobj=input_stream, mode="r|", bufsize=BUF_SIZE + ) as input_tar, + tarfile.open( + fileobj=output_stream, mode="w|", bufsize=BUF_SIZE + ) as output_tar, + ): + _encrypt_backup(input_tar, output_tar, password, nonces) + except (EncryptError, SecureTarError, tarfile.TarError) as err: + LOGGER.warning("Error encrypting backup: %s", err) + error = err + else: + # Pad the output stream to the requested minimum size + padding = max(minimum_size - output_stream.tell(), 0) + output_stream.write(b"\0" * padding) + finally: + # Write an empty chunk to signal the end of the stream + output_stream.write(b"") + except AbortCipher: + LOGGER.debug("Cipher operation aborted") + finally: + on_done(error) + + +def _encrypt_backup( + input_tar: tarfile.TarFile, + output_tar: tarfile.TarFile, + password: str | None, + nonces: list[bytes], +) -> None: + """Encrypt a backup.""" + inner_tar_idx = 0 + for obj in input_tar: + # We compare with PurePath to avoid issues with different path separators, + # for example when backup.json is added as "./backup.json" + if PurePath(obj.name) == PurePath("backup.json"): + # Rewrite the backup.json file to indicate that the backup is encrypted + if not (reader := input_tar.extractfile(obj)): + raise EncryptError + metadata = json_loads_object(reader.read()) + metadata["protected"] = True + updated_metadata_b = json.dumps(metadata).encode() + metadata_obj = copy.deepcopy(obj) + metadata_obj.size = len(updated_metadata_b) + output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) + continue + if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + output_tar.addfile(obj, input_tar.extractfile(obj)) + continue + istf = SecureTarFile( + None, # Not used + gzip=False, + key=password_to_key(password) if password is not None else None, + mode="r", + fileobj=input_tar.extractfile(obj), + nonce=nonces[inner_tar_idx], + ) + inner_tar_idx += 1 + with istf.encrypt(obj) as encrypted: + encrypted_obj = copy.deepcopy(obj) + encrypted_obj.size = encrypted.encrypted_size + output_tar.addfile(encrypted_obj, encrypted) + + +@dataclass(kw_only=True) +class _CipherWorkerStatus: + done: asyncio.Event + error: Exception | None = None + thread: ThreadWithException + + +class _CipherBackupStreamer: + """Encrypt or decrypt a backup.""" + + _cipher_func: Callable[ + [ + IO[bytes], + IO[bytes], + str | None, + Callable[[Exception | None], None], + int, + list[bytes], + ], + None, + ] + + def __init__( + self, + hass: HomeAssistant, + backup: AgentBackup, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, + ) -> None: + """Initialize.""" + self._workers: list[_CipherWorkerStatus] = [] + self._backup = backup + self._hass = hass + self._open_stream = open_stream + self._password = password + self._nonces: list[bytes] = [] + + def size(self) -> int: + """Return the maximum size of the decrypted or encrypted backup.""" + return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE + + def _num_tar_files(self) -> int: + """Return the number of inner tar files.""" + b = self._backup + return len(b.addons) + len(b.folders) + b.homeassistant_included + 1 + + async def open_stream(self) -> AsyncIterator[bytes]: + """Open a stream.""" + + def on_done(error: Exception | None) -> None: + """Call by the worker thread when it's done.""" + worker_status.error = error + self._hass.loop.call_soon_threadsafe(worker_status.done.set) + + stream = await self._open_stream() + reader = AsyncIteratorReader(self._hass, stream) + writer = AsyncIteratorWriter(self._hass) + worker = ThreadWithException( + target=self._cipher_func, + args=[reader, writer, self._password, on_done, self.size(), self._nonces], + ) + worker_status = _CipherWorkerStatus(done=asyncio.Event(), thread=worker) + self._workers.append(worker_status) + worker.start() + return writer + + async def wait(self) -> None: + """Wait for the worker threads to finish.""" + for worker in self._workers: + if not worker.thread.is_alive(): + continue + worker.thread.raise_exc(AbortCipher) + await asyncio.gather(*(worker.done.wait() for worker in self._workers)) + + +class DecryptedBackupStreamer(_CipherBackupStreamer): + """Decrypt a backup.""" + + _cipher_func = staticmethod(decrypt_backup) + + def backup(self) -> AgentBackup: + """Return the decrypted backup.""" + return replace(self._backup, protected=False, size=self.size()) + + +class EncryptedBackupStreamer(_CipherBackupStreamer): + """Encrypt a backup.""" + + def __init__( + self, + hass: HomeAssistant, + backup: AgentBackup, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, + ) -> None: + """Initialize.""" + super().__init__(hass, backup, open_stream, password) + self._nonces = [os.urandom(16) for _ in range(self._num_tar_files())] + + _cipher_func = staticmethod(encrypt_backup) + + def backup(self) -> AgentBackup: + """Return the encrypted backup.""" + return replace(self._backup, protected=True, size=self.size()) + + async def receive_file( hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path ) -> None: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 70fc568c05c..feb762bb50b 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -60,8 +60,10 @@ async def handle_info( "backups": [backup.as_frontend_json() for backup in backups.values()], "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, + "last_non_idle_event": manager.last_non_idle_event, "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, + "state": manager.state, }, ) @@ -198,7 +200,7 @@ async def handle_can_decrypt_on_download( vol.Optional("include_folders"): [vol.Coerce(Folder)], vol.Optional("include_homeassistant", default=True): bool, vol.Optional("name"): str, - vol.Optional("password"): str, + vol.Optional("password"): vol.Any(str, None), } ) @websocket_api.async_response @@ -306,7 +308,10 @@ async def backup_agents_info( connection.send_result( msg["id"], { - "agents": [{"agent_id": agent_id} for agent_id in manager.backup_agents], + "agents": [ + {"agent_id": agent.agent_id, "name": agent.name} + for agent in manager.backup_agents.values() + ], }, ) @@ -341,6 +346,7 @@ async def handle_config_info( @websocket_api.websocket_command( { vol.Required("type"): "backup/config/update", + vol.Optional("agents"): vol.Schema({str: {"protected": bool}}), vol.Optional("create_backup"): vol.Schema( { vol.Optional("agent_ids"): vol.All([str], vol.Unique()), diff --git a/homeassistant/components/baidu/tts.py b/homeassistant/components/baidu/tts.py index cdb6697d143..064dfb8d24c 100644 --- a/homeassistant/components/baidu/tts.py +++ b/homeassistant/components/baidu/tts.py @@ -11,7 +11,7 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_API_KEY -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 7838db16820..c982d59d513 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME diff --git a/homeassistant/components/balboa/config_flow.py b/homeassistant/components/balboa/config_flow.py index fccfeceb331..24375ad4e55 100644 --- a/homeassistant/components/balboa/config_flow.py +++ b/homeassistant/components/balboa/config_flow.py @@ -10,7 +10,7 @@ from pybalboa.exceptions import SpaConnectionError import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac @@ -18,6 +18,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_SYNC_TIME, DOMAIN @@ -55,7 +56,8 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _host: str | None + _host: str + _model: str @staticmethod @callback @@ -63,6 +65,43 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + + error = None + try: + info = await validate_input({CONF_HOST: discovery_info.ip}) + except CannotConnect: + error = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + error = "unknown" + if not error: + self._host = discovery_info.ip + self._model = info["title"] + self.context["title_placeholders"] = {CONF_MODEL: self._model} + return await self.async_step_discovery_confirm() + return self.async_abort(reason=error) + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Allow the user to confirm adding the device.""" + if user_input is not None: + data = {CONF_HOST: self._host} + return self.async_create_entry(title=self._model, data=data) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={CONF_HOST: self._host}, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -78,7 +117,9 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(info["formatted_mac"]) + await self.async_set_unique_id( + info["formatted_mac"], raise_on_progress=False + ) self._abort_if_unique_id_configured() return self.async_create_entry(title=info["title"], data=user_input) diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index d7c15bab88f..867e277358c 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -3,6 +3,14 @@ "name": "Balboa Spa Client", "codeowners": ["@garbled1", "@natekspencer"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + }, + { + "macaddress": "001527*" + } + ], "documentation": "https://www.home-assistant.io/integrations/balboa", "iot_class": "local_push", "loggers": ["pybalboa"], diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 6ced7dfd8c3..c00567a6052 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{model}", "step": { "user": { "description": "Connect to the Balboa Wi-Fi device", @@ -9,6 +10,9 @@ "data_description": { "host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58." } + }, + "confirm_discovery": { + "description": "Do you want to set up the spa at {host}?" } }, "error": { diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index b80e625e8d4..eab2bb3d4e5 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MODEL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.util.ssl import get_default_context from .const import DOMAIN diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py index cab7eae5e25..bf7b06e694a 100644 --- a/homeassistant/components/bang_olufsen/diagnostics.py +++ b/homeassistant/components/bang_olufsen/diagnostics.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import BangOlufsenConfigEntry from .const import DOMAIN diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 74e3db34b68..32f43983991 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -31,8 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import ConditionError, TemplateError -from homeassistant.helpers import condition -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( TrackTemplate, diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index 12174d395f7..18b62f2a506 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -16,10 +16,9 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index 72fa870efbf..fed059247d0 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_VARIABLES, CONF_NAME, UnitOfDataRate from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py index 1c80f62e64f..3a0a6f21f98 100644 --- a/homeassistant/components/beewi_smartclim/sensor.py +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MAC, CONF_NAME, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index e4da2ddc2f4..cb7bc5a043b 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_CURRENCY, CONF_DISPLAY_OPTIONS, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py index 3efddf0b0d7..085c0093073 100644 --- a/homeassistant/components/bizkaibus/sensor.py +++ b/homeassistant/components/bizkaibus/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py index 37672e98e0b..2d39512cbe0 100644 --- a/homeassistant/components/blackbird/media_player.py +++ b/homeassistant/components/blackbird/media_player.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 56a84135a9b..e35dd20eea7 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -13,8 +13,7 @@ from homeassistant.components.camera import Camera from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/blinksticklight/light.py b/homeassistant/components/blinksticklight/light.py index 19ac5f80242..01e5c90aadf 100644 --- a/homeassistant/components/blinksticklight/light.py +++ b/homeassistant/components/blinksticklight/light.py @@ -17,10 +17,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util CONF_SERIAL = "serial" diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py index 8ae091fa95e..a6aedb2c472 100644 --- a/homeassistant/components/blockchain/sensor.py +++ b/homeassistant/components/blockchain/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 12e2f537935..6bb3c101cd1 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -34,7 +34,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN from .coordinator import BluesoundCoordinator diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 5bfe5e7089c..6425aabe12f 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -211,10 +211,16 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> SchemaOptionsFlowHandler | RemoteAdapterOptionsFlowHandler: + ) -> ( + SchemaOptionsFlowHandler + | RemoteAdapterOptionsFlowHandler + | LocalNoPassiveOptionsFlowHandler + ): """Get the options flow for this handler.""" if CONF_SOURCE in config_entry.data: return RemoteAdapterOptionsFlowHandler() + if not (manager := get_manager()) or not manager.supports_passive_scan: + return LocalNoPassiveOptionsFlowHandler() return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) @classmethod @@ -232,3 +238,13 @@ class RemoteAdapterOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Handle options flow.""" return self.async_abort(reason="remote_adapters_not_supported") + + +class LocalNoPassiveOptionsFlowHandler(OptionsFlow): + """Handle a option flow for local adapters with no passive support.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + return self.async_abort(reason="local_adapters_no_passive_support") diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 22f8aa8fdb8..1fcd507da83 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.12.0" + "habluetooth==3.14.0" ] } diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 1b8231c66ca..5f9a380d631 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -35,7 +35,8 @@ } }, "abort": { - "remote_adapters_not_supported": "Bluetooth configuration for remote adapters is not supported." + "remote_adapters_not_supported": "Bluetooth configuration for remote adapters is not supported.", + "local_adapters_no_passive_support": "Local Bluetooth adapters that do not support passive scanning cannot be configured." } } } diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index ca2e0180c00..738a61b6f33 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -39,6 +39,10 @@ def async_load_history_from_system( now_monotonic = monotonic_time_coarse() connectable_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} all_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} + adapter_to_source_address = { + adapter: details[ADAPTER_ADDRESS] + for adapter, details in adapters.adapters.items() + } # Restore local adapters for address, history in adapters.history.items(): @@ -50,7 +54,11 @@ def async_load_history_from_system( BluetoothServiceInfoBleak.from_device_and_advertisement_data( history.device, history.advertisement_data, - history.source, + # history.source is really the adapter name + # for historical compatibility since BlueZ + # does not know the MAC address of the adapter + # so we need to convert it to the source address (MAC) + adapter_to_source_address.get(history.source, history.source), now_monotonic, True, ) diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py index 2829617d09e..d21b11b050f 100644 --- a/homeassistant/components/bluetooth/websocket_api.py +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -7,7 +7,12 @@ from functools import lru_cache, partial import time from typing import Any -from habluetooth import BluetoothScanningMode, HaBluetoothSlotAllocations +from habluetooth import ( + BluetoothScanningMode, + HaBluetoothSlotAllocations, + HaScannerRegistration, + HaScannerRegistrationEvent, +) from home_assistant_bluetooth import BluetoothServiceInfoBleak import voluptuous as vol @@ -16,6 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.json import json_bytes from .api import _get_manager, async_register_callback +from .const import DOMAIN from .match import BluetoothCallbackMatcher from .models import BluetoothChange from .util import InvalidConfigEntryID, InvalidSource, config_entry_id_to_source @@ -26,6 +32,7 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the bluetooth websocket API.""" websocket_api.async_register_command(hass, ws_subscribe_advertisements) websocket_api.async_register_command(hass, ws_subscribe_connection_allocations) + websocket_api.async_register_command(hass, ws_subscribe_scanner_details) @lru_cache(maxsize=1024) @@ -191,3 +198,58 @@ async def ws_subscribe_connection_allocations( connection.send_message( json_bytes(websocket_api.event_message(ws_msg_id, current_allocations)) ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "bluetooth/subscribe_scanner_details", + vol.Optional("config_entry_id"): str, + } +) +@websocket_api.async_response +async def ws_subscribe_scanner_details( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe scanner details websocket command.""" + ws_msg_id = msg["id"] + source: str | None = None + if config_entry_id := msg.get("config_entry_id"): + if ( + not (entry := hass.config_entries.async_get_entry(config_entry_id)) + or entry.domain != DOMAIN + ): + connection.send_error( + ws_msg_id, + "invalid_config_entry_id", + f"Invalid config entry id: {config_entry_id}", + ) + return + source = entry.unique_id + assert source is not None + + def _async_event_message(message: dict[str, Any]) -> None: + connection.send_message( + json_bytes(websocket_api.event_message(ws_msg_id, message)) + ) + + def _async_registration_changed(registration: HaScannerRegistration) -> None: + added_event = HaScannerRegistrationEvent.ADDED + event_type = "add" if registration.event == added_event else "remove" + _async_event_message({event_type: [registration.scanner.details]}) + + manager = _get_manager(hass) + connection.subscriptions[ws_msg_id] = ( + manager.async_register_scanner_registration_callback( + _async_registration_changed, source + ) + ) + connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id))) + if (scanners := manager.async_current_scanners()) and ( + matching_scanners := [ + scanner.details + for scanner in scanners + if source is None or scanner.source == source + ] + ): + _async_event_message({"add": matching_scanners}) diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index 25e620ff15d..25a1aa60a1d 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -24,10 +24,10 @@ from homeassistant.components.device_tracker.legacy import ( ) from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index 1d64d31a248..17d166f2b32 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -27,7 +27,7 @@ from homeassistant.components.device_tracker.legacy import ( ) from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 05fa3e3cab0..287cb226b51 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -9,11 +9,11 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( + config_validation as cv, device_registry as dr, discovery, entity_registry as er, ) -import homeassistant.helpers.config_validation as cv from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN from .coordinator import BMWConfigEntry, BMWDataUpdateCoordinator diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index b8ee9d1e6ae..bfb5a2cd50f 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -63,7 +63,8 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN): ): self._abort_if_unique_id_configured() return self.async_create_entry( - title=self.info.get("name") or user_input[CONF_EMAIL], data=user_input + title=self.info.name or user_input[CONF_EMAIL], + data=user_input, ) return self.async_show_form( diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index d02237e84eb..0511d285afc 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging @@ -12,6 +13,7 @@ from bring_api import ( BringRequestException, ) from bring_api.types import BringItemsResponse, BringList, BringUserSettingsResponse +from mashumaro.mixins.orjson import DataClassORJSONMixin from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL @@ -24,9 +26,13 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class BringData(BringList, BringItemsResponse): +@dataclass(frozen=True) +class BringData(DataClassORJSONMixin): """Coordinator data class.""" + lst: BringList + content: BringItemsResponse + class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): """A Bring Data Update Coordinator.""" @@ -67,11 +73,11 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): return self.data list_dict: dict[str, BringData] = {} - for lst in lists_response["lists"]: - if (ctx := set(self.async_contexts())) and lst["listUuid"] not in ctx: + for lst in lists_response.lists: + if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: continue try: - items = await self.bring.get_list(lst["listUuid"]) + items = await self.bring.get_list(lst.listUuid) except BringRequestException as e: raise UpdateFailed( "Unable to connect and retrieve data from bring" @@ -79,7 +85,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): except BringParseException as e: raise UpdateFailed("Unable to parse response from bring") from e else: - list_dict[lst["listUuid"]] = BringData(**lst, **items) + list_dict[lst.listUuid] = BringData(lst, items) return list_dict diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index f4193a9993c..1dec8f3a5ed 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -2,15 +2,16 @@ from __future__ import annotations +from typing import Any + from homeassistant.core import HomeAssistant from . import BringConfigEntry -from .coordinator import BringData async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: BringConfigEntry -) -> dict[str, BringData]: +) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return config_entry.runtime_data.data + return {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()} diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index a1e0cb2edc0..74076d66df9 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -20,13 +20,13 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): bring_list: BringData, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, bring_list["listUuid"]) + super().__init__(coordinator, bring_list.lst.listUuid) - self._list_uuid = bring_list["listUuid"] + self._list_uuid = bring_list.lst.listUuid self.device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - name=bring_list["name"], + name=bring_list.lst.name, identifiers={ (DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}") }, diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index 71fe733ccf5..ecd3e911078 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["bring_api"], - "requirements": ["bring-api==0.9.1"] + "requirements": ["bring-api==1.0.0"] } diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index bd33ce9bf88..02bd0e50788 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -65,7 +65,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = ( translation_key=BringSensor.LIST_LANGUAGE, value_fn=( lambda lst, settings: x.lower() - if (x := list_language(lst["listUuid"], settings)) + if (x := list_language(lst.lst.listUuid, settings)) else None ), entity_category=EntityCategory.DIAGNOSTIC, @@ -75,7 +75,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = ( BringSensorEntityDescription( key=BringSensor.LIST_ACCESS, translation_key=BringSensor.LIST_ACCESS, - value_fn=lambda lst, _: lst["status"].lower(), + value_fn=lambda lst, _: lst.content.status.value.lower(), entity_category=EntityCategory.DIAGNOSTIC, options=["registered", "shared", "invitation"], device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 75657e2fd64..7ab60084314 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -2,6 +2,7 @@ from __future__ import annotations +from itertools import chain from typing import TYPE_CHECKING import uuid @@ -59,7 +60,7 @@ async def async_setup_entry( SERVICE_PUSH_NOTIFICATION, { vol.Required(ATTR_NOTIFICATION_TYPE): vol.All( - vol.Upper, cv.enum(BringNotificationType) + vol.Upper, vol.Coerce(BringNotificationType) ), vol.Optional(ATTR_ITEM_NAME): cv.string, }, @@ -92,21 +93,21 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): return [ *( TodoItem( - uid=item["uuid"], - summary=item["itemId"], - description=item["specification"] or "", + uid=item.uuid, + summary=item.itemId, + description=item.specification, status=TodoItemStatus.NEEDS_ACTION, ) - for item in self.bring_list["purchase"] + for item in self.bring_list.content.items.purchase ), *( TodoItem( - uid=item["uuid"], - summary=item["itemId"], - description=item["specification"] or "", + uid=item.uuid, + summary=item.itemId, + description=item.specification, status=TodoItemStatus.COMPLETED, ) - for item in self.bring_list["recently"] + for item in self.bring_list.content.items.recently ), ] @@ -119,7 +120,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): """Add an item to the To-do list.""" try: await self.coordinator.bring.save_item( - self.bring_list["listUuid"], + self._list_uuid, item.summary or "", item.description or "", str(uuid.uuid4()), @@ -154,26 +155,25 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): bring_list = self.bring_list - bring_purchase_item = next( - (i for i in bring_list["purchase"] if i["uuid"] == item.uid), + current_item = next( + ( + i + for i in chain( + bring_list.content.items.purchase, bring_list.content.items.recently + ) + if i.uuid == item.uid + ), None, ) - bring_recently_item = next( - (i for i in bring_list["recently"] 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.summary == current_item["itemId"]: + if item.summary == current_item.itemId: try: await self.coordinator.bring.batch_update_list( - bring_list["listUuid"], + self._list_uuid, BringItem( itemId=item.summary or "", spec=item.description or "", @@ -192,10 +192,10 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): else: try: await self.coordinator.bring.batch_update_list( - bring_list["listUuid"], + self._list_uuid, [ BringItem( - itemId=current_item["itemId"], + itemId=current_item.itemId, spec=item.description or "", uuid=item.uid, operation=BringItemOperation.REMOVE, @@ -225,7 +225,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): try: await self.coordinator.bring.batch_update_list( - self.bring_list["listUuid"], + self._list_uuid, [ BringItem( itemId=uid, diff --git a/homeassistant/components/bring/util.py b/homeassistant/components/bring/util.py index b706156a3d3..9a075f7bb89 100644 --- a/homeassistant/components/bring/util.py +++ b/homeassistant/components/bring/util.py @@ -14,27 +14,25 @@ def list_language( """Get the lists language setting.""" try: list_settings = next( - filter( - lambda x: x["listUuid"] == list_uuid, - user_settings["userlistsettings"], - ) + filter(lambda x: x.listUuid == list_uuid, user_settings.userlistsettings) ) - return next( - filter( - lambda x: x["key"] == "listArticleLanguage", - list_settings["usersettings"], + return ( + next( + filter( + lambda x: x.key == "listArticleLanguage", list_settings.usersettings + ) ) - )["value"] + ).value - except (StopIteration, KeyError): + except StopIteration: return None def sum_attributes(bring_list: BringData, attribute: str) -> int: """Count items with given attribute set.""" return sum( - item["attributes"][0]["content"][attribute] - for item in bring_list["purchase"] - if len(item.get("attributes", [])) + getattr(item.attributes[0].content, attribute) + for item in bring_list.content.items.purchase + if item.attributes ) diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index cc3b9dad464..9098440a5c4 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py index cbd06381578..84450573989 100644 --- a/homeassistant/components/bt_home_hub_5/device_tracker.py +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index 29f60bd317f..57ceb01700d 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py index 45ad9028eb0..12f292036df 100644 --- a/homeassistant/components/buienradar/config_flow.py +++ b/homeassistant/components/buienradar/config_flow.py @@ -10,8 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaFlowFormStep, diff --git a/homeassistant/components/button/device_action.py b/homeassistant/components/button/device_action.py index f4db7b619f8..30c0cc36835 100644 --- a/homeassistant/components/button/device_action.py +++ b/homeassistant/components/button/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import DOMAIN, SERVICE_PRESS diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index fb53947a723..c2bf1b2dce1 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index a28c37580ce..b0e59e49a6f 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 28db97a857d..3cc17fae43b 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -53,7 +53,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.network import NoURLAvailableError, get_url, is_hass_url -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.logging import async_create_catching_coro from .const import ( diff --git a/homeassistant/components/ccm15/config_flow.py b/homeassistant/components/ccm15/config_flow.py index 0e49e0929e5..c059796045c 100644 --- a/homeassistant/components/ccm15/config_flow.py +++ b/homeassistant/components/ccm15/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DEFAULT_TIMEOUT, DOMAIN diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index b882f046a8e..0477ebb111c 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py index 2c7398ae172..78bbcc9edbc 100644 --- a/homeassistant/components/cisco_mobility_express/device_tracker.py +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cisco_webex_teams/notify.py b/homeassistant/components/cisco_webex_teams/notify.py index 74d033c62d4..2f76ed8f65a 100644 --- a/homeassistant/components/cisco_webex_teams/notify.py +++ b/homeassistant/components/cisco_webex_teams/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 6cd401989c8..e08b651dd70 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -28,8 +28,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py index 233ffc840c0..04c1305cb13 100644 --- a/homeassistant/components/clementine/media_player.py +++ b/homeassistant/components/clementine/media_player.py @@ -17,7 +17,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py index c8d96d48faf..9a5a5160ada 100644 --- a/homeassistant/components/clickatell/notify.py +++ b/homeassistant/components/clickatell/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_RECIPIENT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py index d00d7b413cc..53f16875d6f 100644 --- a/homeassistant/components/clicksend/notify.py +++ b/homeassistant/components/clicksend/notify.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/clicksend_tts/notify.py b/homeassistant/components/clicksend_tts/notify.py index 6b5f2040448..5a08ccd7988 100644 --- a/homeassistant/components/clicksend_tts/notify.py +++ b/homeassistant/components/clicksend_tts/notify.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 84f166b752e..c9d098d7be6 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -17,8 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py index c97e5bdc0a2..0e3736d9da8 100644 --- a/homeassistant/components/cloud/assist_pipeline.py +++ b/homeassistant/components/cloud/assist_pipeline.py @@ -14,7 +14,7 @@ from homeassistant.components.stt import DOMAIN as STT_DOMAIN from homeassistant.components.tts import DOMAIN as TTS_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import ( DATA_PLATFORMS_SETUP, diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 153d0741770..d42e846259c 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -82,8 +82,7 @@ def async_register_backup_agents_listener( class CloudBackupAgent(BackupAgent): """Cloud backup agent.""" - domain = DOMAIN - name = DOMAIN + domain = name = unique_id = DOMAIN def __init__(self, hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None: """Initialize the cloud backup sync agent.""" diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 4dbee10fbaf..645ff4f9e75 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -22,7 +22,7 @@ from homeassistant.components.tts import ( from homeassistant.config_entries import ConfigEntry 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 import 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 diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py index d55e9ca8f0b..a1f303809d0 100644 --- a/homeassistant/components/cmus/media_player.py +++ b/homeassistant/components/cmus/media_player.py @@ -17,7 +17,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 0d357cce199..530496811d9 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -20,8 +20,8 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, ) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 2b58f2b2f37..3234ec29679 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFl from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from . import CoinbaseConfigEntry, get_accounts from .const import ( diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index 81cd55564b9..775adb6a7d5 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -17,8 +17,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index b47255828e8..ac217eeb353 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -17,8 +17,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_OFFSET, CURRENCY_CENT, UnitOfEnergy from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index 46fc13796a0..f29cc62136b 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN diff --git a/homeassistant/components/comfoconnect/__init__.py b/homeassistant/components/comfoconnect/__init__.py index 4e0671fd134..b28f7a748e1 100644 --- a/homeassistant/components/comfoconnect/__init__.py +++ b/homeassistant/components/comfoconnect/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 6a15e37f3f1..fbe958e6d67 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -48,7 +48,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 2440fcde76c..1832e83e7dd 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -52,8 +52,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index 02453b56376..61cf2aebb31 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -18,7 +18,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.const import CONF_CODE, CONF_HOST, CONF_MODE, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py index 2b86e72e63c..a60cf31a646 100644 --- a/homeassistant/components/concord232/binary_sensor.py +++ b/homeassistant/components/concord232/binary_sensor.py @@ -17,10 +17,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index da50f7e93a1..4a070a87734 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -17,7 +17,7 @@ from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_a from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import DependencyError, Unauthorized -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView, diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 8d0eb72a73b..df5771fe5bb 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations +from dataclasses import dataclass import json import logging +from typing import Any from aiohttp import client_exceptions from pyControl4.account import C4Account @@ -25,14 +27,7 @@ from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import ( API_RETRY_TIMES, - CONF_ACCOUNT, - CONF_CONFIG_LISTENER, CONF_CONTROLLER_UNIQUE_ID, - CONF_DIRECTOR, - CONF_DIRECTOR_ALL_ITEMS, - CONF_DIRECTOR_MODEL, - CONF_DIRECTOR_SW_VERSION, - CONF_UI_CONFIGURATION, DEFAULT_SCAN_INTERVAL, DOMAIN, ) @@ -42,6 +37,23 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER] +@dataclass +class Control4RuntimeData: + """Control4 runtime data.""" + + account: C4Account + controller_unique_id: str + director: C4Director + director_all_items: list[dict[str, Any]] + director_model: str + director_sw_version: str + scan_interval: int + ui_configuration: dict[str, Any] | None + + +type Control4ConfigEntry = ConfigEntry[Control4RuntimeData] + + async def call_c4_api_retry(func, *func_args): """Call C4 API function and retry on failure.""" # Ruff doesn't understand this loop - the exception is always raised after the retries @@ -54,10 +66,8 @@ async def call_c4_api_retry(func, *func_args): raise ConfigEntryNotReady(exception) from exception -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: """Set up Control4 from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {}) account_session = aiohttp_client.async_get_clientsession(hass) config = entry.data @@ -76,10 +86,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: exception, ) return False - entry_data[CONF_ACCOUNT] = account - controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID] - entry_data[CONF_CONTROLLER_UNIQUE_ID] = controller_unique_id + controller_unique_id: str = config[CONF_CONTROLLER_UNIQUE_ID] director_token_dict = await call_c4_api_retry( account.getDirectorBearerToken, controller_unique_id @@ -89,15 +97,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: director = C4Director( config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) - entry_data[CONF_DIRECTOR] = director controller_href = (await call_c4_api_retry(account.getAccountControllers))["href"] - entry_data[CONF_DIRECTOR_SW_VERSION] = await call_c4_api_retry( + director_sw_version = await call_c4_api_retry( account.getControllerOSVersion, controller_href ) _, model, mac_address = controller_unique_id.split("_", 3) - entry_data[CONF_DIRECTOR_MODEL] = model.upper() + director_model = model.upper() device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -106,57 +113,60 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, manufacturer="Control4", name=controller_unique_id, - model=entry_data[CONF_DIRECTOR_MODEL], - sw_version=entry_data[CONF_DIRECTOR_SW_VERSION], + model=director_model, + sw_version=director_sw_version, ) # Store all items found on controller for platforms to use - director_all_items = await director.getAllItemInfo() - director_all_items = json.loads(director_all_items) - entry_data[CONF_DIRECTOR_ALL_ITEMS] = director_all_items - - # Check if OS version is 3 or higher to get UI configuration - entry_data[CONF_UI_CONFIGURATION] = None - if int(entry_data[CONF_DIRECTOR_SW_VERSION].split(".")[0]) >= 3: - entry_data[CONF_UI_CONFIGURATION] = json.loads( - await director.getUiConfiguration() - ) - - # Load options from config entry - entry_data[CONF_SCAN_INTERVAL] = entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + director_all_items: list[dict[str, Any]] = json.loads( + await director.getAllItemInfo() ) - entry_data[CONF_CONFIG_LISTENER] = entry.add_update_listener(update_listener) + # Check if OS version is 3 or higher to get UI configuration + ui_configuration: dict[str, Any] | None = None + if int(director_sw_version.split(".")[0]) >= 3: + ui_configuration = json.loads(await director.getUiConfiguration()) + + # Load options from config entry + scan_interval: int = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + entry.runtime_data = Control4RuntimeData( + account=account, + controller_unique_id=controller_unique_id, + director=director, + director_all_items=director_all_items, + director_model=director_model, + director_sw_version=director_sw_version, + scan_interval=scan_interval, + ui_configuration=ui_configuration, + ) + + entry.async_on_unload(entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: Control4ConfigEntry +) -> None: """Update when config_entry options update.""" _LOGGER.debug("Config entry was updated, rerunning setup") await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - hass.data[DOMAIN][entry.entry_id][CONF_CONFIG_LISTENER]() - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - _LOGGER.debug("Unloaded entry for %s", entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def get_items_of_category(hass: HomeAssistant, entry: ConfigEntry, category: str): +async def get_items_of_category( + hass: HomeAssistant, entry: Control4ConfigEntry, category: str +): """Return a list of all Control4 items with the specified category.""" - director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] return [ item - for item in director_all_items + for item in entry.runtime_data.director_all_items if "categories" in item and category in item["categories"] ] diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 19fae1ef7ca..3ca96ca4e52 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -11,12 +11,7 @@ from pyControl4.director import C4Director from pyControl4.error_handling import NotFound, Unauthorized import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -28,6 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.device_registry import format_mac +from . import Control4ConfigEntry from .const import ( CONF_CONTROLLER_UNIQUE_ID, DEFAULT_SCAN_INTERVAL, @@ -151,7 +147,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: Control4ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/control4/const.py b/homeassistant/components/control4/const.py index 57074c00108..2fe9c42849b 100644 --- a/homeassistant/components/control4/const.py +++ b/homeassistant/components/control4/const.py @@ -7,14 +7,6 @@ MIN_SCAN_INTERVAL = 1 API_RETRY_TIMES = 5 -CONF_ACCOUNT = "account" -CONF_DIRECTOR = "director" -CONF_DIRECTOR_SW_VERSION = "director_sw_version" -CONF_DIRECTOR_MODEL = "director_model" -CONF_DIRECTOR_ALL_ITEMS = "director_all_items" -CONF_UI_CONFIGURATION = "ui_configuration" CONF_CONTROLLER_UNIQUE_ID = "controller_unique_id" -CONF_CONFIG_LISTENER = "config_listener" - CONTROL4_ENTITY_TYPE = 7 diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index 5e57237337c..a26c5f9f413 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -8,21 +8,21 @@ from pyControl4.account import C4Account from pyControl4.director import C4Director from pyControl4.error_handling import BadToken -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import CONF_ACCOUNT, CONF_CONTROLLER_UNIQUE_ID, CONF_DIRECTOR, DOMAIN +from . import Control4ConfigEntry +from .const import CONF_CONTROLLER_UNIQUE_ID _LOGGER = logging.getLogger(__name__) async def _update_variables_for_config_entry( - hass: HomeAssistant, entry: ConfigEntry, variable_names: set[str] + hass: HomeAssistant, entry: Control4ConfigEntry, variable_names: set[str] ) -> dict[int, dict[str, Any]]: """Retrieve data from the Control4 director.""" - director: C4Director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + director = entry.runtime_data.director data = await director.getAllItemVariableValue(variable_names) result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict) for item in data: @@ -31,7 +31,7 @@ async def _update_variables_for_config_entry( async def update_variables_for_config_entry( - hass: HomeAssistant, entry: ConfigEntry, variable_names: set[str] + hass: HomeAssistant, entry: Control4ConfigEntry, variable_names: set[str] ) -> dict[int, dict[str, Any]]: """Try to Retrieve data from the Control4 director for update_coordinator.""" try: @@ -42,8 +42,8 @@ async def update_variables_for_config_entry( return await _update_variables_for_config_entry(hass, entry, variable_names) -async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): - """Store updated authentication and director tokens in hass.data.""" +async def refresh_tokens(hass: HomeAssistant, entry: Control4ConfigEntry): + """Store updated authentication and director tokens in runtime_data.""" config = entry.data account_session = aiohttp_client.async_get_clientsession(hass) @@ -59,6 +59,5 @@ async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): ) _LOGGER.debug("Saving new tokens in hass data") - entry_data = hass.data[DOMAIN][entry.entry_id] - entry_data[CONF_ACCOUNT] = account - entry_data[CONF_DIRECTOR] = director + entry.runtime_data.account = account + entry.runtime_data.director = director diff --git a/homeassistant/components/control4/entity.py b/homeassistant/components/control4/entity.py index fdb22e6578d..f7ca0e1fabc 100644 --- a/homeassistant/components/control4/entity.py +++ b/homeassistant/components/control4/entity.py @@ -10,7 +10,8 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import CONF_CONTROLLER_UNIQUE_ID, DOMAIN +from . import Control4RuntimeData +from .const import DOMAIN class Control4Entity(CoordinatorEntity[Any]): @@ -18,7 +19,7 @@ class Control4Entity(CoordinatorEntity[Any]): def __init__( self, - entry_data: dict, + runtime_data: Control4RuntimeData, coordinator: DataUpdateCoordinator[Any], name: str | None, idx: int, @@ -29,11 +30,11 @@ class Control4Entity(CoordinatorEntity[Any]): ) -> None: """Initialize a Control4 entity.""" super().__init__(coordinator) - self.entry_data = entry_data + self.runtime_data = runtime_data self._attr_name = name self._attr_unique_id = str(idx) self._idx = idx - self._controller_unique_id = entry_data[CONF_CONTROLLER_UNIQUE_ID] + self._controller_unique_id = runtime_data.controller_unique_id self._device_name = device_name self._device_manufacturer = device_manufacturer self._device_model = device_model diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 927f4643619..cedfbeb49c3 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -17,14 +17,12 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from . import get_items_of_category -from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN +from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category +from .const import CONTROL4_ENTITY_TYPE from .director_utils import update_variables_for_config_entry from .entity import Control4Entity @@ -36,15 +34,13 @@ CONTROL4_DIMMER_VARS = ["LIGHT_LEVEL", "Brightness Percent"] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: Control4ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Control4 lights from a config entry.""" - entry_data = hass.data[DOMAIN][entry.entry_id] - scan_interval = entry_data[CONF_SCAN_INTERVAL] - _LOGGER.debug( - "Scan interval = %s", - scan_interval, - ) + runtime_data = entry.runtime_data + _LOGGER.debug("Scan interval = %s", runtime_data.scan_interval) async def async_update_data_non_dimmer() -> dict[int, dict[str, Any]]: """Fetch data from Control4 director for non-dimmer lights.""" @@ -69,14 +65,14 @@ async def async_setup_entry( _LOGGER, name="light", update_method=async_update_data_non_dimmer, - update_interval=timedelta(seconds=scan_interval), + update_interval=timedelta(seconds=runtime_data.scan_interval), ) dimmer_coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]]( hass, _LOGGER, name="light", update_method=async_update_data_dimmer, - update_interval=timedelta(seconds=scan_interval), + update_interval=timedelta(seconds=runtime_data.scan_interval), ) # Fetch initial data so we have data when entities subscribe @@ -118,7 +114,7 @@ async def async_setup_entry( item_is_dimmer = False item_coordinator = non_dimmer_coordinator else: - director = entry_data[CONF_DIRECTOR] + director = runtime_data.director item_variables = await director.getItemVariables(item_id) _LOGGER.warning( ( @@ -132,7 +128,7 @@ async def async_setup_entry( entity_list.append( Control4Light( - entry_data, + runtime_data, item_coordinator, item_name, item_id, @@ -154,7 +150,7 @@ class Control4Light(Control4Entity, LightEntity): def __init__( self, - entry_data: dict, + runtime_data: Control4RuntimeData, coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]], name: str, idx: int, @@ -166,7 +162,7 @@ class Control4Light(Control4Entity, LightEntity): ) -> None: """Initialize Control4 light entity.""" super().__init__( - entry_data, + runtime_data, coordinator, name, idx, @@ -188,7 +184,7 @@ class Control4Light(Control4Entity, LightEntity): This exists so the director token used is always the latest one, without needing to re-init the entire entity. """ - return C4Light(self.entry_data[CONF_DIRECTOR], self._idx) + return C4Light(self.runtime_data.director, self._idx) @property def is_on(self): diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index 9e3421817a3..bd8e3fb38fe 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -18,13 +18,11 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_DIRECTOR, CONF_DIRECTOR_ALL_ITEMS, CONF_UI_CONFIGURATION, DOMAIN +from . import Control4ConfigEntry, Control4RuntimeData from .director_utils import update_variables_for_config_entry from .entity import Control4Entity @@ -67,22 +65,23 @@ class _RoomSource: name: str -async def get_rooms(hass: HomeAssistant, entry: ConfigEntry): +async def get_rooms(hass: HomeAssistant, entry: Control4ConfigEntry): """Return a list of all Control4 rooms.""" - director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] return [ item - for item in director_all_items + for item in entry.runtime_data.director_all_items if "typeName" in item and item["typeName"] == "room" ] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: Control4ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Control4 rooms from a config entry.""" - entry_data = hass.data[DOMAIN][entry.entry_id] - ui_config = entry_data[CONF_UI_CONFIGURATION] + runtime_data = entry.runtime_data + ui_config = runtime_data.ui_configuration # OS 2 will not have a ui_configuration if not ui_config: @@ -93,7 +92,7 @@ async def async_setup_entry( if not all_rooms: return - scan_interval = entry_data[CONF_SCAN_INTERVAL] + scan_interval = runtime_data.scan_interval _LOGGER.debug("Scan interval = %s", scan_interval) async def async_update_data() -> dict[int, dict[str, Any]]: @@ -116,10 +115,7 @@ async def async_setup_entry( # Fetch initial data so we have data when entities subscribe await coordinator.async_refresh() - items_by_id = { - item["id"]: item - for item in hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] - } + items_by_id = {item["id"]: item for item in runtime_data.director_all_items} item_to_parent_map = { k: item["parentId"] for k, item in items_by_id.items() @@ -156,7 +152,7 @@ async def async_setup_entry( hidden = room["roomHidden"] entity_list.append( Control4Room( - entry_data, + runtime_data, coordinator, room["name"], room_id, @@ -182,7 +178,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity): def __init__( self, - entry_data: dict, + runtime_data: Control4RuntimeData, coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]], name: str, room_id: int, @@ -192,7 +188,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity): ) -> None: """Initialize Control4 room entity.""" super().__init__( - entry_data, + runtime_data, coordinator, None, room_id, @@ -220,7 +216,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity): This exists so the director token used is always the latest one, without needing to re-init the entire entity. """ - return C4Room(self.entry_data[CONF_DIRECTOR], self._idx) + return C4Room(self.runtime_data.director, self._idx) def _get_device_from_variable(self, var: str) -> int | None: current_device = self.coordinator.data[self._idx][var] diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 9c1db128f15..b110d53540c 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -48,21 +48,28 @@ from .default_agent import DefaultAgent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult -from .session import ChatMessage, ChatSession, ConverseError, async_get_chat_session +from .session import ( + ChatSession, + Content, + ConverseError, + NativeContent, + async_get_chat_session, +) from .trace import ConversationTraceEventType, async_conversation_trace_append __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", "OLD_HOME_ASSISTANT_AGENT", - "ChatMessage", "ChatSession", + "Content", "ConversationEntity", "ConversationEntityFeature", "ConversationInput", "ConversationResult", "ConversationTraceEventType", "ConverseError", + "NativeContent", "async_conversation_trace_append", "async_converse", "async_get_agent_info", diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index bb815698941..be0387555dc 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -62,7 +62,7 @@ from .const import ( ) from .entity import ConversationEntity from .models import ConversationInput, ConversationResult -from .session import ChatMessage, async_get_chat_session +from .session import Content, async_get_chat_session from .trace import ConversationTraceEventType, async_conversation_trace_append _LOGGER = logging.getLogger(__name__) @@ -374,11 +374,10 @@ class DefaultAgent(ConversationEntity): speech: str = response.speech.get("plain", {}).get("speech", "") chat_session.async_add_message( - ChatMessage( + Content( role="assistant", agent_id=user_input.agent_id, content=speech, - native=response, ) ) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 979ea7538c4..0485cb75fcb 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.1.0", "home-assistant-intents==2025.1.1"] + "requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.28"] } diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py index 2235459954f..43f4cbf427c 100644 --- a/homeassistant/components/conversation/session.py +++ b/homeassistant/components/conversation/session.py @@ -126,7 +126,7 @@ async def async_get_chat_session( else: history = ChatSession(hass, conversation_id, user_input.agent_id) - message: ChatMessage = ChatMessage( + message: Content = Content( role="user", agent_id=user_input.agent_id, content=user_input.text, @@ -169,23 +169,21 @@ class ConverseError(HomeAssistantError): @dataclass -class ChatMessage[_NativeT]: - """Base class for chat messages. +class Content: + """Base class for chat messages.""" - When role is native, the content is to be ignored and message - is only meant for storing the native object. - """ - - role: Literal["system", "assistant", "user", "native"] + role: Literal["system", "assistant", "user"] agent_id: str | None content: str - native: _NativeT | None = field(default=None) - # Validate in post-init that if role is native, there is no content and a native object exists - def __post_init__(self) -> None: - """Validate native message.""" - if self.role == "native" and self.native is None: - raise ValueError("Native message must have a native object") + +@dataclass(frozen=True) +class NativeContent[_NativeT]: + """Native content.""" + + role: str = field(init=False, default="native") + agent_id: str + content: _NativeT @dataclass @@ -196,15 +194,15 @@ class ChatSession[_NativeT]: conversation_id: str agent_id: str | None user_name: str | None = None - messages: list[ChatMessage[_NativeT]] = field( - default_factory=lambda: [ChatMessage(role="system", agent_id=None, content="")] + messages: list[Content | NativeContent[_NativeT]] = field( + default_factory=lambda: [Content(role="system", agent_id=None, content="")] ) extra_system_prompt: str | None = None llm_api: llm.APIInstance | None = None last_updated: datetime = field(default_factory=dt_util.utcnow) @callback - def async_add_message(self, message: ChatMessage[_NativeT]) -> None: + def async_add_message(self, message: Content | NativeContent[_NativeT]) -> None: """Process intent.""" if message.role == "system": raise ValueError("Cannot add system messages to history") @@ -216,7 +214,7 @@ class ChatSession[_NativeT]: @callback def async_get_messages( self, agent_id: str | None = None - ) -> list[ChatMessage[_NativeT]]: + ) -> list[Content | NativeContent[_NativeT]]: """Get messages for a specific agent ID. This will filter out any native message tied to other agent IDs. @@ -328,7 +326,7 @@ class ChatSession[_NativeT]: self.llm_api = llm_api self.user_name = user_name self.extra_system_prompt = extra_system_prompt - self.messages[0] = ChatMessage( + self.messages[0] = Content( role="system", agent_id=user_input.agent_id, content=prompt, diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 24eb54c5694..752e294a8b3 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -5,12 +5,17 @@ from __future__ import annotations from typing import Any from hassil.recognize import RecognizeResult -from hassil.util import PUNCTUATION_ALL +from hassil.util import ( + PUNCTUATION_END, + PUNCTUATION_END_WORD, + PUNCTUATION_START, + PUNCTUATION_START_WORD, +) import voluptuous as vol from homeassistant.const import CONF_COMMAND, CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import ScriptRunResult from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import UNDEFINED, ConfigType @@ -22,7 +27,12 @@ from .models import ConversationInput def has_no_punctuation(value: list[str]) -> list[str]: """Validate result does not contain punctuation.""" for sentence in value: - if PUNCTUATION_ALL.search(sentence): + if ( + PUNCTUATION_START.search(sentence) + or PUNCTUATION_END.search(sentence) + or PUNCTUATION_START_WORD.search(sentence) + or PUNCTUATION_END_WORD.search(sentence) + ): raise vol.Invalid("sentence should not contain punctuation") return value diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index f99f58c2dd6..2ce61306afe 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -14,6 +14,7 @@ from cookidoo_api import ( CookidooIngredientItem, CookidooRequestException, CookidooSubscription, + CookidooUserInfo, ) from homeassistant.config_entries import ConfigEntry @@ -42,6 +43,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): """A Cookidoo Data Update Coordinator.""" config_entry: CookidooConfigEntry + user: CookidooUserInfo def __init__( self, hass: HomeAssistant, cookidoo: Cookidoo, entry: CookidooConfigEntry @@ -59,6 +61,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): async def _async_setup(self) -> None: try: await self.cookidoo.login() + self.user = await self.cookidoo.get_user_info() except CookidooRequestException as e: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/cookidoo/diagnostics.py b/homeassistant/components/cookidoo/diagnostics.py new file mode 100644 index 00000000000..f981317df19 --- /dev/null +++ b/homeassistant/components/cookidoo/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics for the Cookidoo integration.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .coordinator import CookidooConfigEntry + +TO_REDACT = [ + CONF_PASSWORD, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: CookidooConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "data": asdict(entry.runtime_data.data), + "user": asdict(entry.runtime_data.user), + } diff --git a/homeassistant/components/cookidoo/quality_scale.yaml b/homeassistant/components/cookidoo/quality_scale.yaml index 95a35829079..209f2ce5686 100644 --- a/homeassistant/components/cookidoo/quality_scale.yaml +++ b/homeassistant/components/cookidoo/quality_scale.yaml @@ -63,7 +63,7 @@ rules: stale-devices: status: exempt comment: No stale entities possible - diagnostics: todo + diagnostics: done exception-translations: done icon-translations: done reconfiguration-flow: done diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index f0a14aa7951..e84a92328b2 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index acef2cde4d8..a982e99776b 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -20,8 +20,7 @@ from homeassistant.const import ( SERVICE_STOP_COVER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py index b6fdc0a8889..3b2682d4e32 100644 --- a/homeassistant/components/cppm_tracker/device_tracker.py +++ b/homeassistant/components/cppm_tracker/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_API_KEY, CONF_CLIENT_ID, CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType SCAN_INTERVAL = timedelta(seconds=120) diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py index 7f45e99f93d..701bad3f104 100644 --- a/homeassistant/components/cups/sensor.py +++ b/homeassistant/components/cups/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_HOST, CONF_PORT, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 01dec10efe0..7c985b12ba4 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_BASE, CONF_NAME, CONF_QUOTE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py index 5e4880705d5..d7c16d5da09 100644 --- a/homeassistant/components/danfoss_air/__init__.py +++ b/homeassistant/components/danfoss_air/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py index 2d550e48e2f..fa852399b09 100644 --- a/homeassistant/components/datadog/__init__.py +++ b/homeassistant/components/datadog/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index d72496e4d1e..e93b7e14e05 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/debugpy/__init__.py b/homeassistant/components/debugpy/__init__.py index 5caf517a483..cef98211d9e 100644 --- a/homeassistant/components/debugpy/__init__.py +++ b/homeassistant/components/debugpy/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index 3ef14eca657..28dfb603d8b 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -7,7 +7,7 @@ from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_DEVICE_ID, CONF_EVENT, CONF_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import CONF_GESTURE, DOMAIN as DECONZ_DOMAIN from .deconz_event import CONF_DECONZ_ALARM_EVENT, CONF_DECONZ_EVENT diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 576d356bca9..3003fb1008d 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -52,7 +52,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import DeconzConfigEntry from .const import ATTR_DARK, ATTR_ON diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index cef7b98a2c1..a7d14b83aca 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -21,7 +21,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv if TYPE_CHECKING: from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py index 63ab5c2bf02..9ad1d9ced04 100644 --- a/homeassistant/components/decora_wifi/light.py +++ b/homeassistant/components/decora_wifi/light.py @@ -22,7 +22,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index 017a4c5b2fa..7f94f272c0d 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index d58f23464d1..19afe26e8f9 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_WEB_PORT, diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index d088dfb140b..9314fc211de 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from homeassistant import config_entries, setup +from homeassistant import config_entries, core as ha, setup from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -13,7 +13,6 @@ from homeassistant.const import ( Platform, UnitOfSoundPressure, ) -import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index d513bc38250..4e2fa7b3460 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -8,7 +8,7 @@ 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 -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util async def async_setup_entry( diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index 53c1678aa81..6f8ee26f511 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from . import DOMAIN diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 8ce77bcd615..fa3c3e3b2fc 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util async def async_setup_entry( diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index fbc2b660efb..2468c54dde3 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -28,7 +28,7 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_CLOUDY: [], diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py index 2f46cd42294..9e7cebe0702 100644 --- a/homeassistant/components/denon/media_player.py +++ b/homeassistant/components/denon/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/devialet/__init__.py b/homeassistant/components/devialet/__init__.py index 2eccdb2a4b6..be641ad58a5 100644 --- a/homeassistant/components/devialet/__init__.py +++ b/homeassistant/components/devialet/__init__.py @@ -4,29 +4,28 @@ from __future__ import annotations from devialet import DevialetApi -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .coordinator import DevialetConfigEntry, DevialetCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: DevialetConfigEntry) -> bool: """Set up Devialet from a config entry.""" session = async_get_clientsession(hass) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DevialetApi( - entry.data[CONF_HOST], session - ) + client = DevialetApi(entry.data[CONF_HOST], session) + coordinator = DevialetCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DevialetConfigEntry) -> bool: """Unload Devialet config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/devialet/coordinator.py b/homeassistant/components/devialet/coordinator.py index 9cfeb797373..7b022b921f8 100644 --- a/homeassistant/components/devialet/coordinator.py +++ b/homeassistant/components/devialet/coordinator.py @@ -5,6 +5,7 @@ import logging from devialet import DevialetApi +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -14,15 +15,22 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) +type DevialetConfigEntry = ConfigEntry[DevialetCoordinator] + class DevialetCoordinator(DataUpdateCoordinator[None]): """Devialet update coordinator.""" - def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None: + config_entry: DevialetConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: DevialetConfigEntry, client: DevialetApi + ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/devialet/diagnostics.py b/homeassistant/components/devialet/diagnostics.py index ae887dd1c8c..75d6e7aa222 100644 --- a/homeassistant/components/devialet/diagnostics.py +++ b/homeassistant/components/devialet/diagnostics.py @@ -4,18 +4,13 @@ from __future__ import annotations from typing import Any -from devialet import DevialetApi - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import DevialetConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: DevialetConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - client: DevialetApi = hass.data[DOMAIN][entry.entry_id] - - return await client.async_get_diagnostics() + return await entry.runtime_data.client.async_get_diagnostics() diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py index 8789516650a..04ec58723cf 100644 --- a/homeassistant/components/devialet/media_player.py +++ b/homeassistant/components/devialet/media_player.py @@ -9,7 +9,6 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -17,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, SOUND_MODES -from .coordinator import DevialetCoordinator +from .coordinator import DevialetConfigEntry, DevialetCoordinator SUPPORT_DEVIALET = ( MediaPlayerEntityFeature.VOLUME_SET @@ -37,14 +36,12 @@ DEVIALET_TO_HA_FEATURE_MAP = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DevialetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Devialet entry.""" - client = hass.data[DOMAIN][entry.entry_id] - coordinator = DevialetCoordinator(hass, client) - await coordinator.async_config_entry_first_refresh() - - async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)]) + async_add_entities([DevialetMediaPlayerEntity(entry.runtime_data)]) class DevialetMediaPlayerEntity( @@ -55,18 +52,18 @@ class DevialetMediaPlayerEntity( _attr_has_entity_name = True _attr_name = None - def __init__(self, coordinator: DevialetCoordinator, entry: ConfigEntry) -> None: + def __init__(self, coordinator: DevialetCoordinator) -> None: """Initialize the Devialet device.""" - self.coordinator = coordinator super().__init__(coordinator) + entry = coordinator.config_entry self._attr_unique_id = str(entry.unique_id) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer=MANUFACTURER, - model=self.coordinator.client.model, + model=coordinator.client.model, name=entry.data[CONF_NAME], - sw_version=self.coordinator.client.version, + sw_version=coordinator.client.version, ) @callback diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py index 6781b9afaf7..ee427eb1ba6 100644 --- a/homeassistant/components/device_sun_light_trigger/__init__.py +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -29,14 +29,14 @@ from homeassistant.const import ( SUN_EVENT_SUNSET, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change_event, ) from homeassistant.helpers.sun import get_astral_event_next, is_up from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util DOMAIN = "device_sun_light_trigger" CONF_DEVICE_GROUP = "device_group" diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py index 240686ed3bb..91e8dd83b7d 100644 --- a/homeassistant/components/devolo_home_network/image.py +++ b/homeassistant/components/devolo_home_network/image.py @@ -13,7 +13,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import DevoloHomeNetworkConfigEntry from .const import IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index b23b7cef2bd..7bc43f2c3f5 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -33,6 +33,7 @@ from homeassistant.loader import ( async_get_integration, ) from homeassistant.setup import async_get_domain_setup_times +from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType @@ -44,6 +45,7 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +_DIAGNOSTICS_DATA: HassKey[DiagnosticsData] = HassKey(DOMAIN) @dataclass(slots=True) @@ -72,7 +74,7 @@ class DiagnosticsData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Diagnostics from a config entry.""" - hass.data[DOMAIN] = DiagnosticsData() + hass.data[_DIAGNOSTICS_DATA] = DiagnosticsData() await integration_platform.async_process_integration_platforms( hass, DOMAIN, _register_diagnostics_platform @@ -104,7 +106,7 @@ def _register_diagnostics_platform( hass: HomeAssistant, integration_domain: str, platform: DiagnosticsProtocol ) -> None: """Register a diagnostics platform.""" - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] diagnostics_data.platforms[integration_domain] = DiagnosticsPlatformData( getattr(platform, "async_get_config_entry_diagnostics", None), getattr(platform, "async_get_device_diagnostics", None), @@ -118,7 +120,7 @@ def handle_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """List all possible diagnostic handlers.""" - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] result = [ { "domain": domain, @@ -145,7 +147,7 @@ def handle_get( ) -> None: """List all diagnostic handlers for a domain.""" domain = msg["domain"] - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] if (info := diagnostics_data.platforms.get(domain)) is None: connection.send_error( @@ -267,7 +269,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView): if (config_entry := hass.config_entries.async_get_entry(d_id)) is None: return web.Response(status=HTTPStatus.NOT_FOUND) - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] if (info := diagnostics_data.platforms.get(config_entry.domain)) is None: return web.Response(status=HTTPStatus.NOT_FOUND) diff --git a/homeassistant/components/digital_ocean/__init__.py b/homeassistant/components/digital_ocean/__init__.py index e5b62d430b6..306ddc8e9a5 100644 --- a/homeassistant/components/digital_ocean/__init__.py +++ b/homeassistant/components/digital_ocean/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py index 0d4b31faa2c..f0bb6eba049 100644 --- a/homeassistant/components/digital_ocean/binary_sensor.py +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py index 856c9301cfd..409fa63c1c2 100644 --- a/homeassistant/components/digital_ocean/switch.py +++ b/homeassistant/components/digital_ocean/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( SwitchEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index 3cea6ec4dac..3c64b9020c3 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index e17f892a7fe..fee9f8dab3c 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -15,7 +15,7 @@ from homeassistant.components.image_processing import ( ) from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 8c2cfa5e556..9e98178e718 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_HOSTNAME, diff --git a/homeassistant/components/dominos/__init__.py b/homeassistant/components/dominos/__init__.py index 9b11b667e84..6fccecfec5c 100644 --- a/homeassistant/components/dominos/__init__.py +++ b/homeassistant/components/dominos/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components import http from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index 7b055c6dd05..bcc6e7a8050 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -25,8 +25,7 @@ from homeassistant.const import ( CONF_URL, ) from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.pil import draw_box diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index c943fa68766..5090f309c49 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -17,9 +17,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_EVENTS, DOMAIN, PLATFORMS diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 640d6630c18..45f37527ac1 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -10,7 +10,7 @@ import aiohttp from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import DoorBirdEntity from .models import DoorBirdConfigEntry, DoorBirdData diff --git a/homeassistant/components/dovado/__init__.py b/homeassistant/components/dovado/__init__.py index 5f63bbd0b2b..0a5fb602a08 100644 --- a/homeassistant/components/dovado/__init__.py +++ b/homeassistant/components/dovado/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index 013b51bfc8f..e35fdeb2dc0 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_SENSORS, PERCENTAGE, UnitOfInformation from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 75e1103a712..1a45886879a 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path diff --git a/homeassistant/components/dremel_3d_printer/config_flow.py b/homeassistant/components/dremel_3d_printer/config_flow.py index 913180db0f7..0cb5c7d9cfc 100644 --- a/homeassistant/components/dremel_3d_printer/config_flow.py +++ b/homeassistant/components/dremel_3d_printer/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py index bc700456398..52b8f5a7d6e 100644 --- a/homeassistant/components/drop_connect/__init__.py +++ b/homeassistant/components/drop_connect/__init__.py @@ -7,12 +7,11 @@ from typing import TYPE_CHECKING from homeassistant.components import mqtt from homeassistant.components.mqtt import ReceiveMessage -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE, DOMAIN -from .coordinator import DROPDeviceDataUpdateCoordinator +from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -24,7 +23,7 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: DROPConfigEntry) -> bool: """Set up DROP from a config entry.""" # Make sure MQTT integration is enabled and the client is available. @@ -34,9 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if TYPE_CHECKING: assert config_entry.unique_id is not None - drop_data_coordinator = DROPDeviceDataUpdateCoordinator( - hass, config_entry.unique_id - ) + drop_data_coordinator = DROPDeviceDataUpdateCoordinator(hass, config_entry) @callback def mqtt_callback(msg: ReceiveMessage) -> None: @@ -58,15 +55,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_DATA_TOPIC], ) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = drop_data_coordinator + config_entry.runtime_data = drop_data_coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: DROPConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ): - hass.data[DOMAIN].pop(config_entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py index 093c5bcbb8e..bc8cf900610 100644 --- a/homeassistant/components/drop_connect/binary_sensor.py +++ b/homeassistant/components/drop_connect/binary_sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,9 +24,8 @@ from .const import ( DEV_RO_FILTER, DEV_SALT_SENSOR, DEV_SOFTENER, - DOMAIN, ) -from .coordinator import DROPDeviceDataUpdateCoordinator +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -106,7 +104,7 @@ DEVICE_BINARY_SENSORS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP binary sensors from config entry.""" @@ -116,9 +114,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_BINARY_SENSORS: async_add_entities( - DROPBinarySensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + DROPBinarySensor(coordinator, sensor) for sensor in BINARY_SENSORS if sensor.key in DEVICE_BINARY_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] ) diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py index 0861e091153..d37127d89ed 100644 --- a/homeassistant/components/drop_connect/coordinator.py +++ b/homeassistant/components/drop_connect/coordinator.py @@ -16,14 +16,19 @@ from .const import CONF_COMMAND_TOPIC, DOMAIN _LOGGER = logging.getLogger(__name__) +type DROPConfigEntry = ConfigEntry[DROPDeviceDataUpdateCoordinator] + + class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): """DROP device object.""" - config_entry: ConfigEntry + config_entry: DROPConfigEntry - def __init__(self, hass: HomeAssistant, unique_id: str) -> None: + def __init__(self, hass: HomeAssistant, entry: DROPConfigEntry) -> None: """Initialize the device.""" - super().__init__(hass, _LOGGER, name=f"{DOMAIN}-{unique_id}") + super().__init__( + hass, _LOGGER, config_entry=entry, name=f"{DOMAIN}-{entry.unique_id}" + ) self.drop_api = DropAPI() async def set_water(self, value: int) -> None: diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py index ad06576c9f3..9e4c74b67e6 100644 --- a/homeassistant/components/drop_connect/select.py +++ b/homeassistant/components/drop_connect/select.py @@ -8,12 +8,11 @@ import logging from typing import Any from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_DEVICE_TYPE, DEV_HUB, DOMAIN -from .coordinator import DROPDeviceDataUpdateCoordinator +from .const import CONF_DEVICE_TYPE, DEV_HUB +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -50,7 +49,7 @@ DEVICE_SELECTS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP selects from config entry.""" @@ -60,9 +59,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SELECTS: async_add_entities( - DROPSelect(hass.data[DOMAIN][config_entry.entry_id], select) + DROPSelect(coordinator, select) for select in SELECTS if select.key in DEVICE_SELECTS[config_entry.data[CONF_DEVICE_TYPE]] ) diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py index ad123ee13c7..5ec47ed9eb1 100644 --- a/homeassistant/components/drop_connect/sensor.py +++ b/homeassistant/components/drop_connect/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -35,9 +34,8 @@ from .const import ( DEV_PUMP_CONTROLLER, DEV_RO_FILTER, DEV_SOFTENER, - DOMAIN, ) -from .coordinator import DROPDeviceDataUpdateCoordinator +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -243,7 +241,7 @@ DEVICE_SENSORS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP sensors from config entry.""" @@ -253,9 +251,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SENSORS: async_add_entities( - DROPSensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + DROPSensor(coordinator, sensor) for sensor in SENSORS if sensor.key in DEVICE_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] ) diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py index 98841d7ca24..404059d3196 100644 --- a/homeassistant/components/drop_connect/switch.py +++ b/homeassistant/components/drop_connect/switch.py @@ -8,7 +8,6 @@ import logging from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,9 +17,8 @@ from .const import ( DEV_HUB, DEV_PROTECTION_VALVE, DEV_SOFTENER, - DOMAIN, ) -from .coordinator import DROPDeviceDataUpdateCoordinator +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -66,7 +64,7 @@ DEVICE_SWITCHES: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP switches from config entry.""" @@ -76,9 +74,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SWITCHES: async_add_entities( - DROPSwitch(hass.data[DOMAIN][config_entry.entry_id], switch) + DROPSwitch(coordinator, switch) for switch in SWITCHES if switch.key in DEVICE_SWITCHES[config_entry.data[CONF_DEVICE_TYPE]] ) diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 5fc3453fca6..8720be7330f 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -20,10 +20,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _RESOURCE = "https://data.dublinked.ie/cgi-bin/rtpi/realtimebusinformation" diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 557178de571..a49bfde2785 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -18,8 +18,8 @@ from homeassistant.core import ( ServiceCall, callback, ) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass diff --git a/homeassistant/components/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py index f148f4e05ac..064cf52d04d 100644 --- a/homeassistant/components/dwd_weather_warnings/config_flow.py +++ b/homeassistant/components/dwd_weather_warnings/config_flow.py @@ -8,8 +8,7 @@ from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig from .const import CONF_REGION_DEVICE_TRACKER, CONF_REGION_IDENTIFIER, DOMAIN diff --git a/homeassistant/components/dweet/__init__.py b/homeassistant/components/dweet/__init__.py index c1232bab2cf..b43ce3db8c1 100644 --- a/homeassistant/components/dweet/__init__.py +++ b/homeassistant/components/dweet/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py index 10109189eb0..6110f17f826 100644 --- a/homeassistant/components/dweet/sensor.py +++ b/homeassistant/components/dweet/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 691e9dd8275..a7628e78a9a 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -29,8 +29,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py index c9386999fae..4cb8d92c391 100644 --- a/homeassistant/components/ebusd/__init__.py +++ b/homeassistant/components/ebusd/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py index 2eaaddf7e2f..ccd04be585e 100644 --- a/homeassistant/components/ebusd/sensor.py +++ b/homeassistant/components/ebusd/sensor.py @@ -9,8 +9,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/ecoal_boiler/__init__.py b/homeassistant/components/ecoal_boiler/__init__.py index e9b519c7095..0648dfb56bf 100644 --- a/homeassistant/components/ecoal_boiler/__init__.py +++ b/homeassistant/components/ecoal_boiler/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index ae5ee96a6a4..c34211e9ff0 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -3,57 +3,19 @@ from datetime import timedelta from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle -from .const import ( - _LOGGER, - CONF_REFRESH_TOKEN, - DATA_ECOBEE_CONFIG, - DATA_HASS_CONFIG, - DOMAIN, - PLATFORMS, -) +from .const import _LOGGER, CONF_REFRESH_TOKEN, PLATFORMS MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Optional(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA -) - type EcobeeConfigEntry = ConfigEntry[EcobeeData] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Ecobee uses config flow for configuration. - - But, an "ecobee:" entry in configuration.yaml will trigger an import flow - if a config entry doesn't already exist. If ecobee.conf exists, the import - flow will attempt to import it and create a config entry, to assist users - migrating from the old ecobee integration. Otherwise, the user will have to - continue setting up the integration via the config flow. - """ - - hass.data[DATA_ECOBEE_CONFIG] = config.get(DOMAIN, {}) - hass.data[DATA_HASS_CONFIG] = config - - if not hass.config_entries.async_entries(DOMAIN) and hass.data[DATA_ECOBEE_CONFIG]: - # No config entry exists and configuration.yaml config exists, trigger the import flow. - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool: """Set up ecobee via a config entry.""" api_key = entry.data[CONF_API_KEY] diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 4e32990a661..743e2e1ba4b 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -32,8 +32,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import device_registry as dr, entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_platform, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py index 687d9173a66..ac834e92ca8 100644 --- a/homeassistant/components/ecobee/config_flow.py +++ b/homeassistant/components/ecobee/config_flow.py @@ -2,20 +2,15 @@ from typing import Any -from pyecobee import ( - ECOBEE_API_KEY, - ECOBEE_CONFIG_FILENAME, - ECOBEE_REFRESH_TOKEN, - Ecobee, -) +from pyecobee import ECOBEE_API_KEY, Ecobee import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.json import load_json_object -from .const import _LOGGER, CONF_REFRESH_TOKEN, DATA_ECOBEE_CONFIG, DOMAIN +from .const import CONF_REFRESH_TOKEN, DOMAIN + +_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -30,11 +25,6 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" errors = {} - stored_api_key = ( - self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) - if DATA_ECOBEE_CONFIG in self.hass.data - else "" - ) if user_input is not None: # Use the user-supplied API key to attempt to obtain a PIN from ecobee. @@ -47,9 +37,7 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_API_KEY, default=stored_api_key): str} - ), + data_schema=_USER_SCHEMA, errors=errors, ) @@ -75,50 +63,3 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders={"pin": self._ecobee.pin}, ) - - async def async_step_import(self, import_data: None) -> ConfigFlowResult: - """Import ecobee config from configuration.yaml. - - Triggered by async_setup only if a config entry doesn't already exist. - If ecobee.conf exists, we will attempt to validate the credentials - and create an entry if valid. Otherwise, we will delegate to the user - step so that the user can continue the config flow. - """ - try: - legacy_config = await self.hass.async_add_executor_job( - load_json_object, self.hass.config.path(ECOBEE_CONFIG_FILENAME) - ) - config = { - ECOBEE_API_KEY: legacy_config[ECOBEE_API_KEY], - ECOBEE_REFRESH_TOKEN: legacy_config[ECOBEE_REFRESH_TOKEN], - } - except (HomeAssistantError, KeyError): - _LOGGER.debug( - "No valid ecobee.conf configuration found for import, delegating to" - " user step" - ) - return await self.async_step_user( - user_input={ - CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) - } - ) - - ecobee = Ecobee(config=config) - if await self.hass.async_add_executor_job(ecobee.refresh_tokens): - # Credentials found and validated; create the entry. - _LOGGER.debug( - "Valid ecobee configuration found for import, creating configuration" - " entry" - ) - return self.async_create_entry( - title=DOMAIN, - data={ - CONF_API_KEY: ecobee.api_key, - CONF_REFRESH_TOKEN: ecobee.refresh_token, - }, - ) - return await self.async_step_user( - user_input={ - CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) - } - ) diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index d0e9ba8e8e9..115c91eceeb 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -20,8 +20,6 @@ from homeassistant.const import Platform _LOGGER = logging.getLogger(__package__) DOMAIN = "ecobee" -DATA_ECOBEE_CONFIG = "ecobee_config" -DATA_HASS_CONFIG = "ecobee_hass_config" ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_AVAILABLE_SENSORS = "available_sensors" ATTR_ACTIVE_SENSORS = "active_sensors" diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py index 4d5aaa40576..e5350beba8e 100644 --- a/homeassistant/components/ecoforest/__init__.py +++ b/homeassistant/components/ecoforest/__init__.py @@ -11,20 +11,18 @@ from pyecoforest.exceptions import ( EcoforestConnectionError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import EcoforestCoordinator +from .coordinator import EcoforestConfigEntry, EcoforestCoordinator PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EcoforestConfigEntry) -> bool: """Set up Ecoforest from a config entry.""" host = entry.data[CONF_HOST] @@ -41,20 +39,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Error communicating with device %s", host) raise ConfigEntryNotReady from err - coordinator = EcoforestCoordinator(hass, api) + coordinator = EcoforestCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EcoforestConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ecoforest/coordinator.py b/homeassistant/components/ecoforest/coordinator.py index 3b04325bd50..603fde38388 100644 --- a/homeassistant/components/ecoforest/coordinator.py +++ b/homeassistant/components/ecoforest/coordinator.py @@ -6,6 +6,7 @@ from pyecoforest.api import EcoforestApi from pyecoforest.exceptions import EcoforestError from pyecoforest.models.device import Device +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,16 +14,21 @@ from .const import POLLING_INTERVAL _LOGGER = logging.getLogger(__name__) +type EcoforestConfigEntry = ConfigEntry[EcoforestCoordinator] + class EcoforestCoordinator(DataUpdateCoordinator[Device]): """DataUpdateCoordinator to gather data from ecoforest device.""" - def __init__(self, hass: HomeAssistant, api: EcoforestApi) -> None: + def __init__( + self, hass: HomeAssistant, entry: EcoforestConfigEntry, api: EcoforestApi + ) -> None: """Initialize DataUpdateCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name="ecoforest", update_interval=POLLING_INTERVAL, ) diff --git a/homeassistant/components/ecoforest/number.py b/homeassistant/components/ecoforest/number.py index db3275c1fcc..878c150343e 100644 --- a/homeassistant/components/ecoforest/number.py +++ b/homeassistant/components/ecoforest/number.py @@ -8,12 +8,10 @@ from dataclasses import dataclass from pyecoforest.models.device import Device from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import EcoforestCoordinator +from .coordinator import EcoforestConfigEntry from .entity import EcoforestEntity @@ -38,11 +36,11 @@ NUMBER_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcoforestConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Ecoforest number platform.""" - coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data entities = [ EcoforestNumberEntity(coordinator, description) diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index 997b02436cc..0babb476ab6 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfPressure, @@ -24,8 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN -from .coordinator import EcoforestCoordinator +from .coordinator import EcoforestConfigEntry from .entity import EcoforestEntity _LOGGER = logging.getLogger(__name__) @@ -143,10 +141,12 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EcoforestConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ecoforest sensor platform.""" - coordinator: EcoforestCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ EcoforestSensor(coordinator, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py index d643217bebc..de52248e751 100644 --- a/homeassistant/components/ecoforest/switch.py +++ b/homeassistant/components/ecoforest/switch.py @@ -10,12 +10,10 @@ from pyecoforest.api import EcoforestApi from pyecoforest.models.device import Device from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -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 EcoforestCoordinator +from .coordinator import EcoforestConfigEntry from .entity import EcoforestEntity @@ -39,11 +37,11 @@ SWITCH_TYPES: tuple[EcoforestSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcoforestConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Ecoforest switch platform.""" - coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data entities = [ EcoforestSwitchEntity(coordinator, description) for description in SWITCH_TYPES diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 4fd920a5ecc..40bece93599 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -6,7 +6,7 @@ import logging from aiohttp.client_exceptions import ClientError from pyeconet import EcoNetApiInterface -from pyeconet.equipment import EquipmentType +from pyeconet.equipment import Equipment, EquipmentType from pyeconet.errors import ( GenericHTTPError, InvalidCredentialsError, @@ -21,7 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from .const import API_CLIENT, DOMAIN, EQUIPMENT, PUSH_UPDATE +from .const import PUSH_UPDATE _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,12 @@ PLATFORMS = [ INTERVAL = timedelta(minutes=60) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +type EconetConfigEntry = ConfigEntry[dict[EquipmentType, list[Equipment]]] + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: EconetConfigEntry +) -> bool: """Set up EcoNet as config entry.""" email = config_entry.data[CONF_EMAIL] @@ -57,9 +62,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) except (ClientError, GenericHTTPError, InvalidResponseFormat) as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {API_CLIENT: {}, EQUIPMENT: {}}) - hass.data[DOMAIN][API_CLIENT][config_entry.entry_id] = api - hass.data[DOMAIN][EQUIPMENT][config_entry.entry_id] = equipment + + config_entry.runtime_data = equipment await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -89,10 +93,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EconetConfigEntry) -> bool: """Unload a EcoNet config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][API_CLIENT].pop(entry.entry_id) - hass.data[DOMAIN][EQUIPMENT].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index 0f5cb6f92af..13ef8c4713b 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -2,18 +2,17 @@ from __future__ import annotations -from pyeconet.equipment import EquipmentType +from pyeconet.equipment import Equipment, EquipmentType from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( @@ -41,10 +40,12 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet binary sensor based on a config entry.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data all_equipment = equipment[EquipmentType.WATER_HEATER].copy() all_equipment.extend(equipment[EquipmentType.THERMOSTAT].copy()) @@ -62,7 +63,7 @@ class EcoNetBinarySensor(EcoNetEntity, BinarySensorEntity): """Define a Econet binary sensor.""" def __init__( - self, econet_device, description: BinarySensorEntityDescription + self, econet_device: Equipment, description: BinarySensorEntityDescription ) -> None: """Initialize.""" super().__init__(econet_device) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index cdf82f6817f..d46dbd8750a 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -3,7 +3,11 @@ from typing import Any from pyeconet.equipment import EquipmentType -from pyeconet.equipment.thermostat import ThermostatFanMode, ThermostatOperationMode +from pyeconet.equipment.thermostat import ( + Thermostat, + ThermostatFanMode, + ThermostatOperationMode, +) from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, @@ -16,13 +20,13 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry +from .const import DOMAIN from .entity import EcoNetEntity ECONET_STATE_TO_HA = { @@ -51,10 +55,12 @@ SUPPORT_FLAGS_THERMOSTAT = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet thermostat based on a config entry.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data async_add_entities( [ EcoNetThermostat(thermostat) @@ -63,13 +69,13 @@ async def async_setup_entry( ) -class EcoNetThermostat(EcoNetEntity, ClimateEntity): +class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): """Define an Econet thermostat.""" _attr_should_poll = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - def __init__(self, thermostat): + def __init__(self, thermostat: Thermostat) -> None: """Initialize.""" super().__init__(thermostat) self._attr_hvac_modes = [] @@ -90,24 +96,24 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): ) @property - def current_temperature(self): + def current_temperature(self) -> int: """Return the current temperature.""" return self._econet.set_point @property - def current_humidity(self): + def current_humidity(self) -> int: """Return the current humidity.""" return self._econet.humidity @property - def target_humidity(self): + def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" if self._econet.supports_humidifier: return self._econet.dehumidifier_set_point return None @property - def target_temperature(self): + def target_temperature(self) -> int | None: """Return the temperature we try to reach.""" if self.hvac_mode == HVACMode.COOL: return self._econet.cool_set_point @@ -116,14 +122,14 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): return None @property - def target_temperature_low(self): + def target_temperature_low(self) -> int | None: """Return the lower bound temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: return self._econet.heat_set_point return None @property - def target_temperature_high(self): + def target_temperature_high(self) -> int | None: """Return the higher bound temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: return self._econet.cool_set_point @@ -140,7 +146,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): self._econet.set_set_point(None, target_temp_high, target_temp_low) @property - def is_aux_heat(self): + def is_aux_heat(self) -> bool: """Return true if aux heater.""" return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT @@ -169,7 +175,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): self._econet.set_dehumidifier_set_point(humidity) @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the current fan mode.""" econet_fan_mode = self._econet.fan_mode @@ -183,7 +189,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): return _current_fan_mode @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the fan modes.""" return [ ECONET_FAN_STATE_TO_HA[mode] diff --git a/homeassistant/components/econet/const.py b/homeassistant/components/econet/const.py index ee8d4fc8a46..78384f7683d 100644 --- a/homeassistant/components/econet/const.py +++ b/homeassistant/components/econet/const.py @@ -1,7 +1,5 @@ """Constants for Econet integration.""" DOMAIN = "econet" -API_CLIENT = "api_client" -EQUIPMENT = "equipment" PUSH_UPDATE = "econet.push_update" diff --git a/homeassistant/components/econet/entity.py b/homeassistant/components/econet/entity.py index 44488f0b133..2ec8af83dd0 100644 --- a/homeassistant/components/econet/entity.py +++ b/homeassistant/components/econet/entity.py @@ -1,5 +1,7 @@ """Support for EcoNet products.""" +from pyeconet.equipment import Equipment + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -8,18 +10,18 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN, PUSH_UPDATE -class EcoNetEntity(Entity): +class EcoNetEntity[_EquipmentT: Equipment = Equipment](Entity): """Define a base EcoNet entity.""" _attr_should_poll = False - def __init__(self, econet): + def __init__(self, econet: _EquipmentT) -> None: """Initialize.""" self._econet = econet self._attr_name = econet.device_name self._attr_unique_id = f"{econet.device_id}_{econet.device_name}" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to device events.""" await super().async_added_to_hass() self.async_on_remove( @@ -27,12 +29,12 @@ class EcoNetEntity(Entity): ) @callback - def on_update_received(self): + def on_update_received(self) -> None: """Update was pushed from the ecoent API.""" self.async_write_ha_state() @property - def available(self): + def available(self) -> bool: """Return if the device is online or not.""" return self._econet.connected diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py index 19bac8c9e1f..510906d699c 100644 --- a/homeassistant/components/econet/sensor.py +++ b/homeassistant/components/econet/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, @@ -21,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -82,11 +81,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet sensor based on a config entry.""" - data = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + data = entry.runtime_data equipment = data[EquipmentType.WATER_HEATER].copy() equipment.extend(data[EquipmentType.THERMOSTAT].copy()) diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py index e36f6c834b1..9fcd38c860e 100644 --- a/homeassistant/components/econet/switch.py +++ b/homeassistant/components/econet/switch.py @@ -6,14 +6,13 @@ import logging from typing import Any from pyeconet.equipment import EquipmentType -from pyeconet.equipment.thermostat import ThermostatOperationMode +from pyeconet.equipment.thermostat import Thermostat, ThermostatOperationMode 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, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity _LOGGER = logging.getLogger(__name__) @@ -21,21 +20,21 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: EconetConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat switch entity.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data async_add_entities( EcoNetSwitchAuxHeatOnly(thermostat) for thermostat in equipment[EquipmentType.THERMOSTAT] ) -class EcoNetSwitchAuxHeatOnly(EcoNetEntity, SwitchEntity): +class EcoNetSwitchAuxHeatOnly(EcoNetEntity[Thermostat], SwitchEntity): """Representation of a aux_heat_only EcoNet switch.""" - def __init__(self, thermostat) -> None: + def __init__(self, thermostat: Thermostat) -> None: """Initialize EcoNet ventilator platform.""" super().__init__(thermostat) self._attr_name = f"{thermostat.device_name} emergency heat" diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index efe4196993c..cfbff70b580 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -5,7 +5,7 @@ import logging from typing import Any from pyeconet.equipment import EquipmentType -from pyeconet.equipment.water_heater import WaterHeaterOperationMode +from pyeconet.equipment.water_heater import WaterHeater, WaterHeaterOperationMode from homeassistant.components.water_heater import ( STATE_ECO, @@ -17,12 +17,11 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity SCAN_INTERVAL = timedelta(hours=1) @@ -47,10 +46,12 @@ SUPPORT_FLAGS_HEATER = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet water heater based on a config entry.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data async_add_entities( [ EcoNetWaterHeater(water_heater) @@ -60,24 +61,24 @@ async def async_setup_entry( ) -class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): +class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity): """Define an Econet water heater.""" _attr_should_poll = True # Override False default from EcoNetEntity _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - def __init__(self, water_heater): + def __init__(self, water_heater: WaterHeater) -> None: """Initialize.""" super().__init__(water_heater) self.water_heater = water_heater @property - def is_away_mode_on(self): + def is_away_mode_on(self) -> bool: """Return true if away mode is on.""" return self._econet.away @property - def current_operation(self): + def current_operation(self) -> str: """Return current operation.""" econet_mode = self.water_heater.mode _current_op = STATE_OFF @@ -87,7 +88,7 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): return _current_op @property - def operation_list(self): + def operation_list(self) -> list[str]: """List of available operation modes.""" econet_modes = self.water_heater.modes op_list = [] @@ -130,7 +131,7 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): _LOGGER.error("Invalid operation mode: %s", operation_mode) @property - def target_temperature(self): + def target_temperature(self) -> int: """Return the temperature we try to reach.""" return self.water_heater.set_point diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 157d5b4a5ea..188f59f74e4 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==11.0.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b1"] } diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 5dc30a575d7..1047c52e111 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/edimax/switch.py b/homeassistant/components/edimax/switch.py index e0d063eb9fd..5482143fc37 100644 --- a/homeassistant/components/edimax/switch.py +++ b/homeassistant/components/edimax/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 4474893d9b6..62d06a8a535 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -292,8 +292,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the EDL21 sensor.""" - hass.data[DOMAIN] = EDL21(hass, config_entry.data, async_add_entities) - await hass.data[DOMAIN].connect() + api = EDL21(hass, config_entry.data, async_add_entities) + await api.connect() class EDL21: diff --git a/homeassistant/components/egardia/__init__.py b/homeassistant/components/egardia/__init__.py index 89dae7d23c9..eb6b4cd49d8 100644 --- a/homeassistant/components/egardia/__init__.py +++ b/homeassistant/components/egardia/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index cf08f45bed5..a555a87cfbc 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -10,7 +10,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.LIGHT] +PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py new file mode 100644 index 00000000000..16771ba227d --- /dev/null +++ b/homeassistant/components/eheimdigital/climate.py @@ -0,0 +1,139 @@ +"""EHEIM Digital climate.""" + +from typing import Any + +from eheimdigital.heater import EheimDigitalHeater +from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit + +from homeassistant.components.climate import ( + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_TENTHS, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EheimDigitalConfigEntry +from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE +from .coordinator import EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so climate entities can be added as devices are found.""" + coordinator = entry.runtime_data + + async def async_setup_device_entities(device_address: str) -> None: + """Set up the light entities for a device.""" + device = coordinator.hub.devices[device_address] + + if isinstance(device, EheimDigitalHeater): + async_add_entities([EheimDigitalHeaterClimate(coordinator, device)]) + + coordinator.add_platform_callback(async_setup_device_entities) + + for device_address in entry.runtime_data.hub.devices: + await async_setup_device_entities(device_address) + + +class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity): + """Represent an EHEIM Digital heater.""" + + _attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO] + _attr_hvac_mode = HVACMode.OFF + _attr_precision = PRECISION_TENTHS + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE + ) + _attr_target_temperature_step = PRECISION_HALVES + _attr_preset_modes = [PRESET_NONE, HEATER_BIO_MODE, HEATER_SMART_MODE] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_preset_mode = PRESET_NONE + _attr_translation_key = "heater" + + def __init__( + self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater + ) -> None: + """Initialize an EHEIM Digital thermocontrol climate entity.""" + super().__init__(coordinator, device) + self._attr_unique_id = self._device_address + self._async_update_attrs() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + try: + if preset_mode in HEATER_PRESET_TO_HEATER_MODE: + await self._device.set_operation_mode( + HEATER_PRESET_TO_HEATER_MODE[preset_mode] + ) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set a new temperature.""" + try: + if ATTR_TEMPERATURE in kwargs: + await self._device.set_target_temperature(kwargs[ATTR_TEMPERATURE]) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the heating mode.""" + try: + match hvac_mode: + case HVACMode.OFF: + await self._device.set_active(active=False) + case HVACMode.AUTO: + await self._device.set_active(active=True) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + def _async_update_attrs(self) -> None: + if self._device.temperature_unit == HeaterUnit.CELSIUS: + self._attr_min_temp = 18 + self._attr_max_temp = 32 + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + elif self._device.temperature_unit == HeaterUnit.FAHRENHEIT: + self._attr_min_temp = 64 + self._attr_max_temp = 90 + self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + + self._attr_current_temperature = self._device.current_temperature + self._attr_target_temperature = self._device.target_temperature + + if self._device.is_heating: + self._attr_hvac_action = HVACAction.HEATING + self._attr_hvac_mode = HVACMode.AUTO + elif self._device.is_active: + self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVACMode.AUTO + else: + self._attr_hvac_action = HVACAction.OFF + self._attr_hvac_mode = HVACMode.OFF + + match self._device.operation_mode: + case HeaterMode.MANUAL: + self._attr_preset_mode = PRESET_NONE + case HeaterMode.BIO: + self._attr_preset_mode = HEATER_BIO_MODE + case HeaterMode.SMART: + self._attr_preset_mode = HEATER_SMART_MODE diff --git a/homeassistant/components/eheimdigital/const.py b/homeassistant/components/eheimdigital/const.py index 5ed9303be40..61b391b6c63 100644 --- a/homeassistant/components/eheimdigital/const.py +++ b/homeassistant/components/eheimdigital/const.py @@ -2,8 +2,9 @@ from logging import Logger, getLogger -from eheimdigital.types import LightMode +from eheimdigital.types import HeaterMode, LightMode +from homeassistant.components.climate import PRESET_NONE from homeassistant.components.light import EFFECT_OFF LOGGER: Logger = getLogger(__package__) @@ -15,3 +16,12 @@ EFFECT_TO_LIGHT_MODE = { EFFECT_DAYCL_MODE: LightMode.DAYCL_MODE, EFFECT_OFF: LightMode.MAN_MODE, } + +HEATER_BIO_MODE = "bio_mode" +HEATER_SMART_MODE = "smart_mode" + +HEATER_PRESET_TO_HEATER_MODE = { + HEATER_BIO_MODE: HeaterMode.BIO, + HEATER_SMART_MODE: HeaterMode.SMART, + PRESET_NONE: HeaterMode.MANUAL, +} diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index 0e6fa6a0814..ef6f6b10d0a 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -23,6 +23,18 @@ } }, "entity": { + "climate": { + "heater": { + "state_attributes": { + "preset_mode": { + "state": { + "bio_mode": "Bio mode", + "smart_mode": "Smart mode" + } + } + } + } + }, "light": { "channel": { "name": "Channel {channel_id}", diff --git a/homeassistant/components/electrasmart/__init__.py b/homeassistant/components/electrasmart/__init__.py index b8e5eb1bdd8..27cebc9aee9 100644 --- a/homeassistant/components/electrasmart/__init__.py +++ b/homeassistant/components/electrasmart/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import cast - from electrasmart.api import ElectraAPI, ElectraApiError from homeassistant.config_entries import ConfigEntry @@ -12,36 +10,40 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_IMEI, DOMAIN +from .const import CONF_IMEI PLATFORMS: list[Platform] = [Platform.CLIMATE] +type ElectraSmartConfigEntry = ConfigEntry[ElectraAPI] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: ElectraSmartConfigEntry +) -> bool: """Set up Electra Smart Air Conditioner from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data[DOMAIN][entry.entry_id] = ElectraAPI( + api = ElectraAPI( async_get_clientsession(hass), entry.data[CONF_IMEI], entry.data[CONF_TOKEN] ) - try: - await cast(ElectraAPI, hass.data[DOMAIN][entry.entry_id]).fetch_devices() + await api.fetch_devices() except ElectraApiError as exp: raise ConfigEntryNotReady(f"Error communicating with API: {exp}") from exp + entry.async_on_unload(entry.add_update_listener(update_listener)) + entry.runtime_data = api await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: ElectraSmartConfigEntry +) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: ElectraSmartConfigEntry +) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 04e4742554b..84def436dfb 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -24,13 +24,13 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import ElectraSmartConfigEntry from .const import ( API_DELAY, CONSECUTIVE_FAILURE_THRESHOLD, @@ -89,10 +89,12 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ElectraSmartConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Electra AC devices.""" - api: ElectraAPI = hass.data[DOMAIN][entry.entry_id] + api = entry.runtime_data _LOGGER.debug("Discovered %i Electra devices", len(api.devices)) async_add_entities( diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index 8c9a0b3950e..de8d87553a3 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -6,23 +6,25 @@ import aiohttp from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import api -from .const import ACCOUNT_COORDINATOR, DOMAIN, HOP_COORDINATOR from .coordinator import ( ElectricKiwiAccountDataCoordinator, + ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator, + ElectricKiwiRuntimeData, ) PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: ElectricKiwiConfigEntry +) -> bool: """Set up Electric Kiwi from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -44,8 +46,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ek_api = ElectricKiwiApi( api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) ) - hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, ek_api) - account_coordinator = ElectricKiwiAccountDataCoordinator(hass, ek_api) + hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api) + account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api) try: await ek_api.set_active_session() @@ -54,19 +56,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ApiException as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - HOP_COORDINATOR: hop_coordinator, - ACCOUNT_COORDINATOR: account_coordinator, - } + entry.runtime_data = ElectricKiwiRuntimeData( + hop=hop_coordinator, account=account_coordinator + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: ElectricKiwiConfigEntry +) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py index 0b455b045cf..907b6247172 100644 --- a/homeassistant/components/electric_kiwi/const.py +++ b/homeassistant/components/electric_kiwi/const.py @@ -9,6 +9,3 @@ OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" API_BASE_URL = "https://api.electrickiwi.co.nz" SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" - -HOP_COORDINATOR = "hop_coordinator" -ACCOUNT_COORDINATOR = "account_coordinator" diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index a10be5eafdd..2065da5d668 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -1,7 +1,10 @@ """Electric Kiwi coordinators.""" +from __future__ import annotations + import asyncio from collections import OrderedDict +from dataclasses import dataclass from datetime import timedelta import logging @@ -9,6 +12,7 @@ from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,14 +23,31 @@ ACCOUNT_SCAN_INTERVAL = timedelta(hours=6) HOP_SCAN_INTERVAL = timedelta(minutes=20) +@dataclass +class ElectricKiwiRuntimeData: + """ElectricKiwi runtime data.""" + + hop: ElectricKiwiHOPDataCoordinator + account: ElectricKiwiAccountDataCoordinator + + +type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData] + + class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): """ElectricKiwi Account Data object.""" - def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + ek_api: ElectricKiwiApi, + ) -> None: """Initialize ElectricKiwiAccountDataCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name="Electric Kiwi Account Data", update_interval=ACCOUNT_SCAN_INTERVAL, ) @@ -48,11 +69,17 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): """ElectricKiwi HOP Data object.""" - def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + ek_api: ElectricKiwiApi, + ) -> None: """Initialize ElectricKiwiAccountDataCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, # Name of the data. For logging purposes. name="Electric Kiwi HOP Data", # Polling interval. Will only be polled if there are subscribers. diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index a3f073b8ca2..fa111381612 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -5,14 +5,13 @@ from __future__ import annotations import logging from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN, HOP_COORDINATOR -from .coordinator import ElectricKiwiHOPDataCoordinator +from .const import ATTRIBUTION +from .coordinator import ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator _LOGGER = logging.getLogger(__name__) ATTR_EK_HOP_SELECT = "hop_select" @@ -25,12 +24,12 @@ HOP_SELECT = SelectEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Electric Kiwi select setup.""" - hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][ - HOP_COORDINATOR - ] + hop_coordinator = entry.runtime_data.hop _LOGGER.debug("Setting up select entity") async_add_entities([ElectricKiwiSelectHOPEntity(hop_coordinator, HOP_SELECT)]) diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 7672466106b..e070f9495c1 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -14,16 +14,16 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ACCOUNT_COORDINATOR, ATTRIBUTION, DOMAIN, HOP_COORDINATOR +from .const import ATTRIBUTION from .coordinator import ( ElectricKiwiAccountDataCoordinator, + ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator, ) @@ -122,12 +122,12 @@ HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Electric Kiwi Sensors Setup.""" - account_coordinator: ElectricKiwiAccountDataCoordinator = hass.data[DOMAIN][ - entry.entry_id - ][ACCOUNT_COORDINATOR] + account_coordinator = entry.runtime_data.account entities: list[SensorEntity] = [ ElectricKiwiAccountEntity( @@ -137,9 +137,7 @@ async def async_setup_entry( for description in ACCOUNT_SENSOR_TYPES ] - hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][ - HOP_COORDINATOR - ] + hop_coordinator = entry.runtime_data.hop entities.extend( [ ElectricKiwiHOPEntity(hop_coordinator, description) diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py index 7c9f76824e8..1a5490da0a5 100644 --- a/homeassistant/components/eliqonline/sensor.py +++ b/homeassistant/components/eliqonline/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, UnitOfPower from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 34a35fbeb09..5286b7ad66f 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -32,7 +32,7 @@ from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.network import is_ip_address from .const import ( diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index f1ecf626263..ab51b6fe281 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -19,8 +19,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/elmax/__init__.py b/homeassistant/components/elmax/__init__.py index d85e5778a39..ec293be8273 100644 --- a/homeassistant/components/elmax/__init__.py +++ b/homeassistant/components/elmax/__init__.py @@ -2,14 +2,10 @@ from __future__ import annotations -from datetime import timedelta -import logging - from elmax_api.exceptions import ElmaxBadLoginError from elmax_api.http import Elmax, ElmaxLocal, GenericElmax from elmax_api.model.panel import PanelEntry -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -27,17 +23,13 @@ from .const import ( CONF_ELMAX_PANEL_PIN, CONF_ELMAX_PASSWORD, CONF_ELMAX_USERNAME, - DOMAIN, ELMAX_PLATFORMS, - POLLING_SECONDS, ) -from .coordinator import ElmaxCoordinator - -_LOGGER = logging.getLogger(__name__) +from .coordinator import ElmaxConfigEntry, ElmaxCoordinator async def _load_elmax_panel_client( - entry: ConfigEntry, + entry: ElmaxConfigEntry, ) -> tuple[GenericElmax, PanelEntry]: # Connection mode was not present in initial version, default to cloud if not set mode = entry.data.get(CONF_ELMAX_MODE, CONF_ELMAX_MODE_CLOUD) @@ -87,7 +79,7 @@ async def _check_cloud_panel_status(client: Elmax, panel_id: str) -> PanelEntry: return panel -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> bool: """Set up elmax-cloud from a config entry.""" try: client, panel = await _load_elmax_panel_client(entry) @@ -98,11 +90,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # if there is something wrong with user credentials coordinator = ElmaxCoordinator( hass=hass, - logger=_LOGGER, + entry=entry, elmax_api_client=client, panel=panel, - name=f"Elmax Cloud {entry.entry_id}", - update_interval=timedelta(seconds=POLLING_SECONDS), ) async def _async_on_hass_stop(_: Event) -> None: @@ -117,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() # Store a global reference to the coordinator for later use - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(async_reload_entry)) @@ -126,15 +116,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, ELMAX_PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, ELMAX_PLATFORMS) diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py index 841b94a3d72..139c9080c15 100644 --- a/homeassistant/components/elmax/alarm_control_panel.py +++ b/homeassistant/components/elmax/alarm_control_panel.py @@ -13,23 +13,22 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, InvalidStateError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax area platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data known_devices = set() def _discover_new_devices(): diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py index ec51f861819..351c386a084 100644 --- a/homeassistant/components/elmax/binary_sensor.py +++ b/homeassistant/components/elmax/binary_sensor.py @@ -8,22 +8,20 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -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 ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax sensor platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data known_devices = set() def _discover_new_devices(): diff --git a/homeassistant/components/elmax/coordinator.py b/homeassistant/components/elmax/coordinator.py index 844a3413089..abcc098359e 100644 --- a/homeassistant/components/elmax/coordinator.py +++ b/homeassistant/components/elmax/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from asyncio import timeout from datetime import timedelta -from logging import Logger +import logging from elmax_api.exceptions import ( ElmaxApiError, @@ -22,11 +22,16 @@ from elmax_api.model.panel import PanelEntry, PanelStatus from elmax_api.push.push import PushNotificationHandler from httpx import ConnectError, ConnectTimeout +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEFAULT_TIMEOUT +from .const import DEFAULT_TIMEOUT, POLLING_SECONDS + +_LOGGER = logging.getLogger(__name__) + +type ElmaxConfigEntry = ConfigEntry[ElmaxCoordinator] class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): @@ -37,11 +42,9 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): def __init__( self, hass: HomeAssistant, - logger: Logger, + entry: ElmaxConfigEntry, elmax_api_client: GenericElmax, panel: PanelEntry, - name: str, - update_interval: timedelta, ) -> None: """Instantiate the object.""" self._client = elmax_api_client @@ -49,7 +52,11 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): self._state_by_endpoint = {} self._push_notification_handler = None super().__init__( - hass=hass, logger=logger, name=name, update_interval=update_interval + hass=hass, + config_entry=entry, + logger=_LOGGER, + name=f"Elmax Cloud {entry.entry_id}", + update_interval=timedelta(seconds=POLLING_SECONDS), ) @property diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index 403bc51dbff..e98477fe496 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -9,12 +9,10 @@ from elmax_api.model.command import CoverCommand from elmax_api.model.cover_status import CoverStatus 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 .const import DOMAIN -from .coordinator import ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity _LOGGER = logging.getLogger(__name__) @@ -28,11 +26,11 @@ _COMMAND_BY_MOTION_STATUS = { # Maps the stop command to use for every cover mo async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax cover platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Add the cover feature only if supported by the current panel. if coordinator.data is None or not coordinator.data.cover_feature: return diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py index d0e52c556f6..70faa44cf01 100644 --- a/homeassistant/components/elmax/switch.py +++ b/homeassistant/components/elmax/switch.py @@ -8,12 +8,10 @@ from elmax_api.model.command import SwitchCommand from elmax_api.model.panel import PanelStatus 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 -from .coordinator import ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity _LOGGER = logging.getLogger(__name__) @@ -21,11 +19,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax switch platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data known_devices = set() def _discover_new_devices(): diff --git a/homeassistant/components/elv/__init__.py b/homeassistant/components/elv/__init__.py index 208d19a0f8e..97f08c786f4 100644 --- a/homeassistant/components/elv/__init__.py +++ b/homeassistant/components/elv/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "elv" diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index 21ee6449c11..812e58ecc19 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -24,10 +24,10 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 291ecad0bd3..1920e06a8e8 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -38,8 +38,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py index 00af1fec6c6..2ab00d6ca42 100644 --- a/homeassistant/components/emoncms_history/__init__.py +++ b/homeassistant/components/emoncms_history/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.event import track_point_in_time from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 3e229d07b6c..556831496c6 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .config import ( diff --git a/homeassistant/components/emulated_kasa/__init__.py b/homeassistant/components/emulated_kasa/__init__.py index 408d8c4eff8..11f4ce80490 100644 --- a/homeassistant/components/emulated_kasa/__init__.py +++ b/homeassistant/components/emulated_kasa/__init__.py @@ -15,8 +15,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.template import Template, is_template_string from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py index 4ebd31730bf..d4466f47ef2 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -7,7 +7,7 @@ from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .binding import EmulatedRoku diff --git a/homeassistant/components/energenie_power_sockets/__init__.py b/homeassistant/components/energenie_power_sockets/__init__.py index 12ddb0d1389..0496c6f9b92 100644 --- a/homeassistant/components/energenie_power_sockets/__init__.py +++ b/homeassistant/components/energenie_power_sockets/__init__.py @@ -8,12 +8,14 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from .const import CONF_DEVICE_API_ID, DOMAIN +from .const import CONF_DEVICE_API_ID PLATFORMS = [Platform.SWITCH] +type EnergenieConfigEntry = ConfigEntry[PowerStripUSB] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: EnergenieConfigEntry) -> bool: """Set up Energenie Power Sockets.""" try: powerstrip: PowerStripUSB | None = get_device(entry.data[CONF_DEVICE_API_ID]) @@ -26,19 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Can't access Energenie Power Sockets, will retry later." ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = powerstrip + entry.runtime_data = powerstrip + entry.async_on_unload(powerstrip.release) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EnergenieConfigEntry) -> bool: """Unload config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - powerstrip = hass.data[DOMAIN].pop(entry.entry_id) - powerstrip.release() - - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/energenie_power_sockets/switch.py b/homeassistant/components/energenie_power_sockets/switch.py index 1d5b9ed5197..e4fb7653e5e 100644 --- a/homeassistant/components/energenie_power_sockets/switch.py +++ b/homeassistant/components/energenie_power_sockets/switch.py @@ -7,22 +7,22 @@ from pyegps.exceptions import EgpsException from pyegps.powerstrip import PowerStrip from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EnergenieConfigEntry from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EnergenieConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add EGPS sockets for passed config_entry in HA.""" - powerstrip: PowerStrip = hass.data[DOMAIN][config_entry.entry_id] + powerstrip = config_entry.runtime_data async_add_entities( ( diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 199d18d6b07..eec92c32f98 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -29,8 +29,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import unit_conversion -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, unit_conversion from homeassistant.util.unit_system import METRIC_SYSTEM from .const import DOMAIN diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py index 6dcec5ec218..c1db27c1c34 100644 --- a/homeassistant/components/enocean/__init__.py +++ b/homeassistant/components/enocean/__init__.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DATA_ENOCEAN, DOMAIN, ENOCEAN_DONGLE @@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Unload ENOcean config entry.""" + """Unload EnOcean config entry.""" enocean_dongle = hass.data[DATA_ENOCEAN][ENOCEAN_DONGLE] enocean_dongle.unload() diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index 01e39f96510..26039036ca0 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/enocean/config_flow.py b/homeassistant/components/enocean/config_flow.py index 2452d27b168..fd25b0c6ce1 100644 --- a/homeassistant/components/enocean/config_flow.py +++ b/homeassistant/components/enocean/config_flow.py @@ -1,4 +1,4 @@ -"""Config flows for the ENOcean integration.""" +"""Config flows for the EnOcean integration.""" from typing import Any @@ -6,6 +6,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from . import dongle from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER @@ -15,7 +20,7 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN): """Handle the enOcean config flows.""" VERSION = 1 - MANUAL_PATH_VALUE = "Custom path" + MANUAL_PATH_VALUE = "manual" def __init__(self) -> None: """Initialize the EnOcean config flow.""" @@ -52,14 +57,24 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN): return self.create_enocean_entry(user_input) errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH} - bridges = await self.hass.async_add_executor_job(dongle.detect) - if len(bridges) == 0: + devices = await self.hass.async_add_executor_job(dongle.detect) + if len(devices) == 0: return await self.async_step_manual(user_input) + devices.append(self.MANUAL_PATH_VALUE) - bridges.append(self.MANUAL_PATH_VALUE) return self.async_show_form( step_id="detect", - data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(bridges)}), + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE): SelectSelector( + SelectSelectorConfig( + options=devices, + translation_key="devices", + mode=SelectSelectorMode.LIST, + ) + ) + } + ), errors=errors, ) diff --git a/homeassistant/components/enocean/const.py b/homeassistant/components/enocean/const.py index 3624493b42e..0f3271655d8 100644 --- a/homeassistant/components/enocean/const.py +++ b/homeassistant/components/enocean/const.py @@ -1,4 +1,4 @@ -"""Constants for the ENOcean integration.""" +"""Constants for the EnOcean integration.""" import logging diff --git a/homeassistant/components/enocean/dongle.py b/homeassistant/components/enocean/dongle.py index 2d9a3f8787e..43214b12064 100644 --- a/homeassistant/components/enocean/dongle.py +++ b/homeassistant/components/enocean/dongle.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) class EnOceanDongle: """Representation of an EnOcean dongle. - The dongle is responsible for receiving the ENOcean frames, + The dongle is responsible for receiving the EnOcean frames, creating devices if needed, and dispatching messages to platforms. """ @@ -53,7 +53,7 @@ class EnOceanDongle: def callback(self, packet): """Handle EnOcean device's callback. - This is the callback function called by python-enocan whenever there + This is the callback function called by python-enocean whenever there is an incoming packet. """ @@ -63,7 +63,7 @@ class EnOceanDongle: def detect(): - """Return a list of candidate paths for USB ENOcean dongles. + """Return a list of candidate paths for USB EnOcean dongles. This method is currently a bit simplistic, it may need to be improved to support more configurations and OS. diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index aae84e73848..6586714c1b6 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 98e32ce1a4f..2a4b9364d81 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/enocean/strings.json b/homeassistant/components/enocean/strings.json index 1a6f08cbf37..9baf4386eda 100644 --- a/homeassistant/components/enocean/strings.json +++ b/homeassistant/components/enocean/strings.json @@ -1,16 +1,23 @@ { "config": { + "flow_title": "{name}", "step": { "detect": { - "title": "Select the path to your EnOcean dongle", + "description": "Select your EnOcean USB dongle.", "data": { - "path": "USB dongle path" + "device": "USB dongle" + }, + "data_description": { + "device": "Path to your EnOcean USB dongle." } }, "manual": { - "title": "Enter the path to your EnOcean dongle", + "description": "Enter the path to your EnOcean USB dongle.", "data": { - "path": "[%key:component::enocean::config::step::detect::data::path%]" + "device": "[%key:component::enocean::config::step::detect::data::device%]" + }, + "data_description": { + "device": "[%key:component::enocean::config::step::detect::data_description::device%]" } } }, @@ -20,5 +27,12 @@ "abort": { "invalid_dongle_path": "Invalid dongle path" } + }, + "selector": { + "devices": { + "options": { + "manual": "Custom path" + } + } } } diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index d92b998e731..8eb2b32ac7b 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, INVALID_AUTH_ERRORS diff --git a/homeassistant/components/enphase_envoy/entity.py b/homeassistant/components/enphase_envoy/entity.py index 491951625ee..04987d861d2 100644 --- a/homeassistant/components/enphase_envoy/entity.py +++ b/homeassistant/components/enphase_envoy/entity.py @@ -2,13 +2,22 @@ from __future__ import annotations -from pyenphase import EnvoyData +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate +from httpx import HTTPError +from pyenphase import EnvoyData +from pyenphase.exceptions import EnvoyError + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import EnphaseUpdateCoordinator +ACTIONERRORS = (EnvoyError, HTTPError) + class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]): """Defines a base envoy entity.""" @@ -33,3 +42,29 @@ class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]): data = self.coordinator.envoy.data assert data is not None return data + + +def exception_handler[_EntityT: EnvoyBaseEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Enphase Envoy calls to handle exceptions. + + A decorator that wraps the passed in function, catches enphase_envoy errors. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except ACTIONERRORS as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_error", + translation_placeholders={ + "host": self.coordinator.envoy.host, + "args": error.args[0], + "action": func.__name__, + "entity": self.entity_id, + }, + ) from error + + return handler diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 589dc52f71d..e99c45c5c7a 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -400,6 +400,9 @@ }, "envoy_error": { "message": "Error communicating with Envoy API on {host}: {args}" + }, + "action_error": { + "message": "Failed to execute {action} for {entity}, host: {host}: {args}" } } } diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 7074f341cc8..8a3ca493562 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator -from .entity import EnvoyBaseEntity +from .entity import EnvoyBaseEntity, exception_handler PARALLEL_UPDATES = 1 @@ -147,11 +147,13 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert enpower is not None return self.entity_description.value_fn(enpower) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Enpower switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Enpower switch.""" await self.entity_description.turn_off_fn(self.envoy) @@ -195,11 +197,13 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert relay is not None return self.entity_description.value_fn(relay) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on (close) the dry contact.""" if await self.entity_description.turn_on_fn(self.envoy, self.relay_id): self.async_write_ha_state() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off (open) the dry contact.""" if await self.entity_description.turn_off_fn(self.envoy, self.relay_id): @@ -252,11 +256,13 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert self.data.tariff.storage_settings is not None return self.entity_description.value_fn(self.data.tariff.storage_settings) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the storage settings switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the storage switch.""" await self.entity_description.turn_off_fn(self.envoy) diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index f88bb99cea0..8fa8a06e369 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -20,12 +20,11 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util API_CLIENT_NAME = "homeassistant-{}" diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 0b6eadf6d13..6afea2f983d 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -5,14 +5,12 @@ import logging from env_canada import ECAirQuality, ECRadar, ECWeather -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from .const import CONF_STATION, DOMAIN -from .coordinator import ECDataUpdateCoordinator +from .const import CONF_STATION +from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5) @@ -22,14 +20,13 @@ PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool: """Set up EC as config entry.""" lat = config_entry.data.get(CONF_LATITUDE) lon = config_entry.data.get(CONF_LONGITUDE) station = config_entry.data.get(CONF_STATION) lang = config_entry.data.get(CONF_LANGUAGE, "English") - coordinators = {} errors = 0 weather_data = ECWeather( @@ -37,31 +34,31 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinates=(lat, lon), language=lang.lower(), ) - coordinators["weather_coordinator"] = ECDataUpdateCoordinator( - hass, weather_data, "weather", DEFAULT_WEATHER_UPDATE_INTERVAL + weather_coordinator = ECDataUpdateCoordinator( + hass, config_entry, weather_data, "weather", DEFAULT_WEATHER_UPDATE_INTERVAL ) try: - await coordinators["weather_coordinator"].async_config_entry_first_refresh() + await weather_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: errors = errors + 1 _LOGGER.warning("Unable to retrieve Environment Canada weather") radar_data = ECRadar(coordinates=(lat, lon)) - coordinators["radar_coordinator"] = ECDataUpdateCoordinator( - hass, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL + radar_coordinator = ECDataUpdateCoordinator( + hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL ) try: - await coordinators["radar_coordinator"].async_config_entry_first_refresh() + await radar_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: errors = errors + 1 _LOGGER.warning("Unable to retrieve Environment Canada radar") aqhi_data = ECAirQuality(coordinates=(lat, lon)) - coordinators["aqhi_coordinator"] = ECDataUpdateCoordinator( - hass, aqhi_data, "AQHI", DEFAULT_WEATHER_UPDATE_INTERVAL + aqhi_coordinator = ECDataUpdateCoordinator( + hass, config_entry, aqhi_data, "AQHI", DEFAULT_WEATHER_UPDATE_INTERVAL ) try: - await coordinators["aqhi_coordinator"].async_config_entry_first_refresh() + await aqhi_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: errors = errors + 1 _LOGGER.warning("Unable to retrieve Environment Canada AQHI") @@ -69,31 +66,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if errors == 3: raise ConfigEntryNotReady - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = coordinators + config_entry.runtime_data = ECRuntimeData( + aqhi_coordinator=aqhi_coordinator, + radar_coordinator=radar_coordinator, + weather_coordinator=weather_coordinator, + ) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok - - -def device_info(config_entry: ConfigEntry) -> DeviceInfo: - """Build and return the device info for EC.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, config_entry.entry_id)}, - manufacturer="Environment Canada", - name=config_entry.title, - configuration_url="https://weather.gc.ca/", - ) + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 1625cd253da..3ba059e2206 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -2,10 +2,10 @@ from __future__ import annotations +from env_canada import ECRadar import voluptuous as vol from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, @@ -14,8 +14,8 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import device_info -from .const import ATTR_OBSERVATION_TIME, DOMAIN +from .const import ATTR_OBSERVATION_TIME +from .coordinator import ECConfigEntry, ECDataUpdateCoordinator SERVICE_SET_RADAR_TYPE = "set_radar_type" SET_RADAR_TYPE_SCHEMA: VolDictType = { @@ -25,12 +25,12 @@ SET_RADAR_TYPE_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ECConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["radar_coordinator"] - async_add_entities([ECCamera(coordinator)]) + coordinator = config_entry.runtime_data.radar_coordinator + async_add_entities([ECCameraEntity(coordinator)]) platform = async_get_current_platform() platform.async_register_entity_service( @@ -40,13 +40,13 @@ async def async_setup_entry( ) -class ECCamera(CoordinatorEntity, Camera): +class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera): """Implementation of an Environment Canada radar camera.""" _attr_has_entity_name = True _attr_translation_key = "radar" - def __init__(self, coordinator): + def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None: """Initialize the camera.""" super().__init__(coordinator) Camera.__init__(self) @@ -55,7 +55,7 @@ class ECCamera(CoordinatorEntity, Camera): self._attr_unique_id = f"{coordinator.config_entry.unique_id}-radar" self._attr_attribution = self.radar_object.metadata["attribution"] self._attr_entity_registry_enabled_default = False - self._attr_device_info = device_info(coordinator.config_entry) + self._attr_device_info = coordinator.device_info self.content_type = "image/gif" diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py index 8e77b309c78..e31e847cd2d 100644 --- a/homeassistant/components/environment_canada/coordinator.py +++ b/homeassistant/components/environment_canada/coordinator.py @@ -1,29 +1,67 @@ """Coordinator for the Environment Canada (EC) component.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta import logging import xml.etree.ElementTree as ET -from env_canada import ec_exc +from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type ECConfigEntry = ConfigEntry[ECRuntimeData] +type ECDataType = ECAirQuality | ECRadar | ECWeather -class ECDataUpdateCoordinator(DataUpdateCoordinator): + +@dataclass +class ECRuntimeData: + """Class to hold EC runtime data.""" + + aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality] + radar_coordinator: ECDataUpdateCoordinator[ECRadar] + weather_coordinator: ECDataUpdateCoordinator[ECWeather] + + +class ECDataUpdateCoordinator[DataT: ECDataType](DataUpdateCoordinator[DataT]): """Class to manage fetching EC data.""" - def __init__(self, hass, ec_data, name, update_interval): + config_entry: ECConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ECConfigEntry, + ec_data: DataT, + name: str, + update_interval: timedelta, + ) -> None: """Initialize global EC data updater.""" super().__init__( - hass, _LOGGER, name=f"{DOMAIN} {name}", update_interval=update_interval + hass, + _LOGGER, + config_entry=entry, + name=f"{DOMAIN} {name}", + update_interval=update_interval, ) self.ec_data = ec_data self.last_update_success = False + self.device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Environment Canada", + configuration_url="https://weather.gc.ca/", + ) - async def _async_update_data(self): + async def _async_update_data(self) -> DataT: """Fetch data from EC.""" try: await self.ec_data.update() diff --git a/homeassistant/components/environment_canada/diagnostics.py b/homeassistant/components/environment_canada/diagnostics.py index 0fb565fda59..024cca15f12 100644 --- a/homeassistant/components/environment_canada/diagnostics.py +++ b/homeassistant/components/environment_canada/diagnostics.py @@ -5,23 +5,21 @@ 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_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import ECConfigEntry TO_REDACT = {CONF_LATITUDE, CONF_LONGITUDE} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: ECConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinators = hass.data[DOMAIN][config_entry.entry_id] - weather_coord = coordinators["weather_coordinator"] - return { "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), - "weather_data": dict(weather_coord.ec_data.conditions), + "weather_data": dict( + config_entry.runtime_data.weather_coordinator.ec_data.conditions + ), } diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 1a5d096203d..989667fb1ac 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -6,13 +6,14 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any +from env_canada import ECWeather + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LOCATION, DEGREE, @@ -27,8 +28,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import device_info -from .const import ATTR_STATION, DOMAIN +from .const import ATTR_STATION +from .coordinator import ECConfigEntry, ECDataType, ECDataUpdateCoordinator ATTR_TIME = "alert time" @@ -251,32 +252,44 @@ ALERT_TYPES: tuple[ECSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ECConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] - sensors: list[ECBaseSensor] = [ECSensor(coordinator, desc) for desc in SENSOR_TYPES] - sensors.extend([ECAlertSensor(coordinator, desc) for desc in ALERT_TYPES]) - aqhi_coordinator = hass.data[DOMAIN][config_entry.entry_id]["aqhi_coordinator"] - sensors.append(ECSensor(aqhi_coordinator, AQHI_SENSOR)) + weather_coordinator = config_entry.runtime_data.weather_coordinator + sensors: list[ECBaseSensorEntity] = [ + ECSensorEntity(weather_coordinator, desc) for desc in SENSOR_TYPES + ] + sensors.extend( + [ECAlertSensorEntity(weather_coordinator, desc) for desc in ALERT_TYPES] + ) + + sensors.append( + ECSensorEntity(config_entry.runtime_data.aqhi_coordinator, AQHI_SENSOR) + ) async_add_entities(sensors) -class ECBaseSensor(CoordinatorEntity, SensorEntity): +class ECBaseSensorEntity[DataT: ECDataType]( + CoordinatorEntity[ECDataUpdateCoordinator[DataT]], SensorEntity +): """Environment Canada sensor base.""" entity_description: ECSensorEntityDescription _attr_has_entity_name = True - def __init__(self, coordinator, description): + def __init__( + self, + coordinator: ECDataUpdateCoordinator[DataT], + description: ECSensorEntityDescription, + ) -> None: """Initialize the base sensor.""" super().__init__(coordinator) self.entity_description = description self._ec_data = coordinator.ec_data self._attr_attribution = self._ec_data.metadata["attribution"] self._attr_unique_id = f"{coordinator.config_entry.title}-{description.key}" - self._attr_device_info = device_info(coordinator.config_entry) + self._attr_device_info = coordinator.device_info @property def native_value(self): @@ -287,10 +300,14 @@ class ECBaseSensor(CoordinatorEntity, SensorEntity): return value -class ECSensor(ECBaseSensor): +class ECSensorEntity[DataT: ECDataType](ECBaseSensorEntity[DataT]): """Environment Canada sensor for conditions.""" - def __init__(self, coordinator, description): + def __init__( + self, + coordinator: ECDataUpdateCoordinator[DataT], + description: ECSensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator, description) self._attr_extra_state_attributes = { @@ -299,7 +316,7 @@ class ECSensor(ECBaseSensor): } -class ECAlertSensor(ECBaseSensor): +class ECAlertSensorEntity(ECBaseSensorEntity[ECWeather]): """Environment Canada sensor for alerts.""" @property diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 1871062c2e9..156b9f4152b 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from env_canada import ECWeather + from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -27,7 +29,6 @@ from homeassistant.components.weather import ( SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfLength, UnitOfPressure, @@ -38,8 +39,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import device_info from .const import DOMAIN +from .coordinator import ECConfigEntry, ECDataUpdateCoordinator # Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/ # docs/current_conditions_icon_code_descriptions_e.csv @@ -61,11 +62,10 @@ ICON_CONDITION_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ECConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] entity_registry = er.async_get(hass) # Remove hourly entity from legacy config entries @@ -76,7 +76,7 @@ async def async_setup_entry( ): entity_registry.async_remove(hourly_entity_id) - async_add_entities([ECWeather(coordinator)]) + async_add_entities([ECWeatherEntity(config_entry.runtime_data.weather_coordinator)]) def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str: @@ -84,7 +84,9 @@ def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> st return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}" -class ECWeather(SingleCoordinatorWeatherEntity): +class ECWeatherEntity( + SingleCoordinatorWeatherEntity[ECDataUpdateCoordinator[ECWeather]] +): """Representation of a weather condition.""" _attr_has_entity_name = True @@ -96,7 +98,7 @@ class ECWeather(SingleCoordinatorWeatherEntity): WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) - def __init__(self, coordinator): + def __init__(self, coordinator: ECDataUpdateCoordinator[ECWeather]) -> None: """Initialize Environment Canada weather.""" super().__init__(coordinator) self.ec_data = coordinator.ec_data @@ -105,7 +107,7 @@ class ECWeather(SingleCoordinatorWeatherEntity): self._attr_unique_id = _calculate_unique_id( coordinator.config_entry.unique_id, False ) - self._attr_device_info = device_info(coordinator.config_entry) + self._attr_device_info = coordinator.device_info @property def native_temperature(self): diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 0146b650c22..919704a6728 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index ce65178b8d8..9d1b6d0d7a1 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.const import ATTR_ENTITY_ID, CONF_CODE from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index cedad8b76e2..f92be005db6 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -33,7 +33,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/epic_games_store/__init__.py b/homeassistant/components/epic_games_store/__init__.py index af25eb98137..d9fb3bee529 100644 --- a/homeassistant/components/epic_games_store/__init__.py +++ b/homeassistant/components/epic_games_store/__init__.py @@ -2,34 +2,29 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import EGSCalendarUpdateCoordinator +from .coordinator import EGSCalendarUpdateCoordinator, EGSConfigEntry PLATFORMS: list[Platform] = [ Platform.CALENDAR, ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EGSConfigEntry) -> bool: """Set up Epic Games Store from a config entry.""" coordinator = EGSCalendarUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EGSConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/epic_games_store/calendar.py b/homeassistant/components/epic_games_store/calendar.py index 2ebb381341e..5df1d6b756d 100644 --- a/homeassistant/components/epic_games_store/calendar.py +++ b/homeassistant/components/epic_games_store/calendar.py @@ -7,25 +7,24 @@ from datetime import datetime from typing import Any from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry 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 .const import DOMAIN, CalendarType -from .coordinator import EGSCalendarUpdateCoordinator +from .coordinator import EGSCalendarUpdateCoordinator, EGSConfigEntry DateRange = namedtuple("DateRange", ["start", "end"]) # noqa: PYI024 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: EGSConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the local calendar platform.""" - coordinator: EGSCalendarUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ EGSCalendar(coordinator, entry.entry_id, CalendarType.FREE), diff --git a/homeassistant/components/epic_games_store/coordinator.py b/homeassistant/components/epic_games_store/coordinator.py index d9c48f5da02..0653a3da9b3 100644 --- a/homeassistant/components/epic_games_store/coordinator.py +++ b/homeassistant/components/epic_games_store/coordinator.py @@ -20,13 +20,15 @@ SCAN_INTERVAL = timedelta(days=1) _LOGGER = logging.getLogger(__name__) +type EGSConfigEntry = ConfigEntry[EGSCalendarUpdateCoordinator] + class EGSCalendarUpdateCoordinator( DataUpdateCoordinator[dict[str, list[dict[str, Any]]]] ): """Class to manage fetching data from the Epic Game Store.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: EGSConfigEntry) -> None: """Initialize.""" self._api = EpicGamesStoreAPI( entry.data[CONF_LANGUAGE], @@ -37,6 +39,7 @@ class EGSCalendarUpdateCoordinator( super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/epion/__init__.py b/homeassistant/components/epion/__init__.py index fec975c5098..c04c77f760d 100644 --- a/homeassistant/components/epion/__init__.py +++ b/homeassistant/components/epion/__init__.py @@ -4,30 +4,25 @@ from __future__ import annotations from epion import Epion -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import EpionCoordinator +from .coordinator import EpionConfigEntry, EpionCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EpionConfigEntry) -> bool: """Set up the Epion coordinator from a config entry.""" api = Epion(entry.data[CONF_API_KEY]) - coordinator = EpionCoordinator(hass, api) + coordinator = EpionCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EpionConfigEntry) -> bool: """Unload Epion config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/epion/coordinator.py b/homeassistant/components/epion/coordinator.py index 3eb7efb5dc7..9eb31331097 100644 --- a/homeassistant/components/epion/coordinator.py +++ b/homeassistant/components/epion/coordinator.py @@ -5,6 +5,7 @@ from typing import Any from epion import Epion, EpionAuthenticationError, EpionConnectionError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,15 +14,20 @@ from .const import REFRESH_INTERVAL _LOGGER = logging.getLogger(__name__) +type EpionConfigEntry = ConfigEntry[EpionCoordinator] + class EpionCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Epion data update coordinator.""" - def __init__(self, hass: HomeAssistant, epion_api: Epion) -> None: + def __init__( + self, hass: HomeAssistant, entry: EpionConfigEntry, epion_api: Epion + ) -> None: """Initialize the Epion coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name="Epion", update_interval=REFRESH_INTERVAL, ) diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py index 4717c095bfe..78027813ffa 100644 --- a/homeassistant/components/epion/sensor.py +++ b/homeassistant/components/epion/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -23,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import EpionCoordinator +from .coordinator import EpionConfigEntry, EpionCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -59,11 +58,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: EpionConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add an Epion entry.""" - coordinator: EpionCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ EpionSensor(coordinator, epion_device_id, description) diff --git a/homeassistant/components/epson/__init__.py b/homeassistant/components/epson/__init__.py index 715b55824b4..27dbaa93734 100644 --- a/homeassistant/components/epson/__init__.py +++ b/homeassistant/components/epson/__init__.py @@ -13,13 +13,15 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_CONNECTION_TYPE, DOMAIN, HTTP +from .const import CONF_CONNECTION_TYPE, HTTP from .exceptions import CannotConnect, PoweredOff PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) +type EpsonConfigEntry = ConfigEntry[Projector] + async def validate_projector( hass: HomeAssistant, @@ -45,7 +47,7 @@ async def validate_projector( return epson_proj -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EpsonConfigEntry) -> bool: """Set up epson from a config entry.""" projector = await validate_projector( hass=hass, @@ -54,23 +56,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: check_power=False, check_powered_on=False, ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = projector + entry.runtime_data = projector await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(projector.close) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EpsonConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - projector = hass.data[DOMAIN].pop(entry.entry_id) - projector.close() - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: EpsonConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug( "Migrating configuration from version %s.%s", diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index a901e9df216..e0eac4a1cfb 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -45,6 +45,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EpsonConfigEntry from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE _LOGGER = logging.getLogger(__name__) @@ -52,13 +53,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EpsonConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Epson projector from a config entry.""" - projector: Projector = hass.data[DOMAIN][config_entry.entry_id] projector_entity = EpsonProjectorMediaPlayer( - projector=projector, + projector=config_entry.runtime_data, unique_id=config_entry.unique_id or config_entry.entry_id, entry=config_entry, ) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 43f524516a8..bab62723c82 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.1.1"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"] } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 5934c9a6f68..fee2531fa20 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( __version__ as ha_version, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py index 20d0d651bba..d1bb0bb77ff 100644 --- a/homeassistant/components/esphome/datetime.py +++ b/homeassistant/components/esphome/datetime.py @@ -8,7 +8,7 @@ from functools import partial from aioesphomeapi import DateTimeInfo, DateTimeState from homeassistant.components.datetime import DateTimeEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 455a3f8d105..ff08e5f578a 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -19,9 +19,11 @@ import voluptuous as vol from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_platform, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index b382622281e..93d6c53e590 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -41,9 +41,11 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + template, +) from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import ( diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4682be1c5c7..ecc7afb3661 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,9 +16,9 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==28.0.1", + "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.1.1" + "bleak-esphome==2.2.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py index e64b596a119..3e48307e8bf 100644 --- a/homeassistant/components/etherscan/sensor.py +++ b/homeassistant/components/etherscan/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/eufy/__init__.py b/homeassistant/components/eufy/__init__.py index 8ebe3e08843..57f90503049 100644 --- a/homeassistant/components/eufy/__init__.py +++ b/homeassistant/components/eufy/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "eufy" diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py index 95ad8a15d1c..dcce52612ee 100644 --- a/homeassistant/components/eufy/light.py +++ b/homeassistant/components/eufy/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util EUFYHOME_MAX_KELVIN = 6500 EUFYHOME_MIN_KELVIN = 2700 diff --git a/homeassistant/components/eufylife_ble/__init__.py b/homeassistant/components/eufylife_ble/__init__.py index f66cf7df30d..8a58c50c8e4 100644 --- a/homeassistant/components/eufylife_ble/__init__.py +++ b/homeassistant/components/eufylife_ble/__init__.py @@ -6,17 +6,15 @@ from eufylife_ble_client import EufyLifeBLEDevice from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from .const import DOMAIN -from .models import EufyLifeData +from .models import EufyLifeConfigEntry, EufyLifeData PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EufyLifeConfigEntry) -> bool: """Set up EufyLife device from a config entry.""" address = entry.unique_id assert address is not None @@ -45,11 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EufyLifeData( - address, - model, - client, - ) + entry.runtime_data = EufyLifeData(address, model, client) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -63,9 +57,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EufyLifeConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/eufylife_ble/models.py b/homeassistant/components/eufylife_ble/models.py index eb937fc4f3d..26154a74fac 100644 --- a/homeassistant/components/eufylife_ble/models.py +++ b/homeassistant/components/eufylife_ble/models.py @@ -6,6 +6,10 @@ from dataclasses import dataclass from eufylife_ble_client import EufyLifeBLEDevice +from homeassistant.config_entries import ConfigEntry + +type EufyLifeConfigEntry = ConfigEntry[EufyLifeData] + @dataclass class EufyLifeData: diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index 5e3ae64aabf..d9cef45ce4d 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -6,7 +6,6 @@ from typing import Any from eufylife_ble_client import MODEL_TO_NAME -from homeassistant import config_entries from homeassistant.components.bluetooth import async_address_present from homeassistant.components.sensor import ( RestoreSensor, @@ -20,19 +19,18 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import DOMAIN -from .models import EufyLifeData +from .models import EufyLifeConfigEntry, EufyLifeData IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN} async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: EufyLifeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the EufyLife sensors.""" - data: EufyLifeData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities = [ EufyLifeWeightSensorEntity(data), diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py index 2ba47978353..ae159d77240 100644 --- a/homeassistant/components/everlights/light.py +++ b/homeassistant/components/everlights/light.py @@ -21,11 +21,11 @@ from homeassistant.components.light import ( from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index d5bc3a564a2..7fb7430a044 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -4,38 +4,32 @@ from __future__ import annotations import pyevilgenius -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import DOMAIN -from .coordinator import EvilGeniusUpdateCoordinator +from .coordinator import EvilGeniusConfigEntry, EvilGeniusUpdateCoordinator PLATFORMS = [Platform.LIGHT] UPDATE_INTERVAL = 10 -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EvilGeniusConfigEntry) -> bool: """Set up Evil Genius Labs from a config entry.""" coordinator = EvilGeniusUpdateCoordinator( hass, - entry.title, + entry, pyevilgenius.EvilGeniusDevice( entry.data["host"], aiohttp_client.async_get_clientsession(hass) ), ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EvilGeniusConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/evil_genius_labs/coordinator.py b/homeassistant/components/evil_genius_labs/coordinator.py index 9f0f0df02af..202dcaf6ba7 100644 --- a/homeassistant/components/evil_genius_labs/coordinator.py +++ b/homeassistant/components/evil_genius_labs/coordinator.py @@ -10,11 +10,16 @@ from typing import cast from aiohttp import ContentTypeError import pyevilgenius +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator UPDATE_INTERVAL = 10 +_LOGGER = logging.getLogger(__name__) + +type EvilGeniusConfigEntry = ConfigEntry[EvilGeniusUpdateCoordinator] + class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): """Update coordinator for Evil Genius data.""" @@ -24,14 +29,18 @@ class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): product: dict | None def __init__( - self, hass: HomeAssistant, name: str, client: pyevilgenius.EvilGeniusDevice + self, + hass: HomeAssistant, + entry: EvilGeniusConfigEntry, + client: pyevilgenius.EvilGeniusDevice, ) -> None: """Initialize the data update coordinator.""" self.client = client super().__init__( hass, - logging.getLogger(__name__), - name=name, + _LOGGER, + config_entry=entry, + name=entry.title, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) diff --git a/homeassistant/components/evil_genius_labs/diagnostics.py b/homeassistant/components/evil_genius_labs/diagnostics.py index c9c79acc1bb..371e0c85b35 100644 --- a/homeassistant/components/evil_genius_labs/diagnostics.py +++ b/homeassistant/components/evil_genius_labs/diagnostics.py @@ -5,20 +5,18 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import EvilGeniusUpdateCoordinator +from .coordinator import EvilGeniusConfigEntry TO_REDACT = {"wiFiSsidDefault", "wiFiSSID"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: EvilGeniusConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: EvilGeniusUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { "info": async_redact_data(coordinator.info, TO_REDACT), diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py index 3556672dcce..a6d1d9531b5 100644 --- a/homeassistant/components/evil_genius_labs/light.py +++ b/homeassistant/components/evil_genius_labs/light.py @@ -7,12 +7,10 @@ from typing import Any, cast from homeassistant.components import light from homeassistant.components.light import ColorMode, LightEntity, LightEntityFeature -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 EvilGeniusUpdateCoordinator +from .coordinator import EvilGeniusConfigEntry, EvilGeniusUpdateCoordinator from .entity import EvilGeniusEntity from .util import update_when_done @@ -22,12 +20,11 @@ FIB_NO_EFFECT = "Solid Color" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EvilGeniusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Evil Genius light platform.""" - coordinator: EvilGeniusUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([EvilGeniusLight(coordinator)]) + async_add_entities([EvilGeniusLight(config_entry.runtime_data)]) class EvilGeniusLight(EvilGeniusEntity, LightEntity): diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 612131919d4..97f7c2db54d 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -29,16 +29,15 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ACCESS_TOKEN, diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index c71831fa4bc..64e7367bc32 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -34,7 +34,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ATTR_DURATION_DAYS, diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index b5842c1073a..a42d8ef7582 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -16,7 +16,7 @@ from evohomeasync2.schema.const import ( from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import EvoBroker, EvoService from .const import DOMAIN diff --git a/homeassistant/components/evohome/helpers.py b/homeassistant/components/evohome/helpers.py index f84d2945779..0e2de36eb47 100644 --- a/homeassistant/components/evohome/helpers.py +++ b/homeassistant/components/evohome/helpers.py @@ -11,7 +11,7 @@ from typing import Any import evohomeasync2 as evo from homeassistant.const import CONF_SCAN_INTERVAL -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index a50e16b5dda..2c3cf9de12d 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -29,7 +29,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER from .entity import EvoChild diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 6885304e0de..43a71458fb2 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -11,7 +11,6 @@ from pyezviz.exceptions import ( PyEzvizError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -22,12 +21,11 @@ from .const import ( CONF_FFMPEG_ARGUMENTS, CONF_RFSESSION_ID, CONF_SESSION_ID, - DATA_COORDINATOR, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, ) -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -50,9 +48,8 @@ PLATFORMS_BY_TYPE: dict[str, list] = { } -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bool: """Set up EZVIZ from a config entry.""" - hass.data.setdefault(DOMAIN, {}) sensor_type: str = entry.data[CONF_TYPE] ezviz_client = None @@ -90,20 +87,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from error coordinator = EzvizDataUpdateCoordinator( - hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + hass, entry, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(_async_update_listener)) # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. # Cameras are accessed via local RTSP stream with unique credentials per camera. # Separate camera entities allow for credential changes per camera. - if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]: - for item in hass.config_entries.async_entries(domain=DOMAIN): + if sensor_type == ATTR_TYPE_CAMERA: + for item in hass.config_entries.async_loaded_entries(domain=DOMAIN): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: _LOGGER.debug("Reload Ezviz main account with camera entry") await hass.config_entries.async_reload(item.entry_id) @@ -116,19 +113,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bool: """Unload a config entry.""" sensor_type = entry.data[CONF_TYPE] - unload_ok = await hass.config_entries.async_unload_platforms( + return await hass.config_entries.async_unload_platforms( entry, PLATFORMS_BY_TYPE[sensor_type] ) - if sensor_type == ATTR_TYPE_CLOUD and unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: EzvizConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py index f30a7852b4e..66a76df2cdc 100644 --- a/homeassistant/components/ezviz/alarm_control_panel.py +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -15,14 +15,13 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER -from .coordinator import EzvizDataUpdateCoordinator +from .const import DOMAIN, MANUFACTURER +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -49,12 +48,12 @@ ALARM_TYPE = EzvizAlarmControlPanelEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Ezviz alarm control panel.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data device_info = DeviceInfo( identifiers={(DOMAIN, entry.unique_id)}, # type: ignore[arg-type] diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index c13375cb487..6f0d87c8218 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -7,12 +7,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -34,12 +32,12 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py index 3c89677da09..b99674b0693 100644 --- a/homeassistant/components/ezviz/button.py +++ b/homeassistant/components/ezviz/button.py @@ -11,13 +11,11 @@ from pyezviz.constants import SupportExt from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -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 DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -68,12 +66,12 @@ BUTTON_ENTITIES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ button based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data # Add button entities if supportExt value indicates PTZ capbility. # Could be missing or "0" for unsupported. diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 3c4a5f70ff4..d96fc949c86 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -10,11 +10,7 @@ from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.components.stream import CONF_USE_WALLCLOCK_AS_TIMESTAMPS -from homeassistant.config_entries import ( - SOURCE_IGNORE, - SOURCE_INTEGRATION_DISCOVERY, - ConfigEntry, -) +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_INTEGRATION_DISCOVERY from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery_flow @@ -26,26 +22,25 @@ from homeassistant.helpers.entity_platform import ( from .const import ( ATTR_SERIAL, CONF_FFMPEG_ARGUMENTS, - DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DOMAIN, SERVICE_WAKE_DEVICE, ) -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ cameras based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data camera_entities = [] diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index a7551737c10..845656c1d1d 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -17,12 +17,7 @@ from pyezviz.exceptions import ( from pyezviz.test_cam_rtsp import TestRTSPAuth import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -48,6 +43,7 @@ from .const import ( EU_URL, RUSSIA_URL, ) +from .coordinator import EzvizConfigEntry _LOGGER = logging.getLogger(__name__) DEFAULT_OPTIONS = { @@ -148,7 +144,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> EzvizOptionsFlowHandler: + def async_get_options_flow( + config_entry: EzvizConfigEntry, + ) -> EzvizOptionsFlowHandler: """Get the options flow for this handler.""" return EzvizOptionsFlowHandler() diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index 651110dd5d7..e6de538335c 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -33,6 +33,3 @@ RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" - -# Data -DATA_COORDINATOR = "coordinator" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index c983371f4f8..0830784a501 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -13,6 +13,7 @@ from pyezviz.exceptions import ( PyEzvizError, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,19 +22,32 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type EzvizConfigEntry = ConfigEntry[EzvizDataUpdateCoordinator] + class EzvizDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching EZVIZ data.""" def __init__( - self, hass: HomeAssistant, *, api: EzvizClient, api_timeout: int + self, + hass: HomeAssistant, + entry: EzvizConfigEntry, + *, + api: EzvizClient, + api_timeout: int, ) -> None: """Initialize global EZVIZ data updater.""" self.ezviz_client = api self._api_timeout = api_timeout update_interval = timedelta(seconds=30) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 73c09244222..d4c7a267b1e 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -8,14 +8,14 @@ from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription -from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity _LOGGER = logging.getLogger(__name__) @@ -27,13 +27,13 @@ IMAGE_TYPE = ImageEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ image entities based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizLastMotion(hass, coordinator, camera) for camera in coordinator.data diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py index c35b53b47b7..145c8b1ca20 100644 --- a/homeassistant/components/ezviz/light.py +++ b/homeassistant/components/ezviz/light.py @@ -8,7 +8,6 @@ from pyezviz.constants import DeviceCatagories, DeviceSwitchType, SupportExt from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -17,8 +16,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -26,12 +24,12 @@ BRIGHTNESS_RANGE = (1, 255) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ lights based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizLight(coordinator, camera) diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index 08fbd3afb34..9e8a20f36dd 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -16,14 +16,12 @@ from pyezviz.exceptions import ( ) from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizBaseEntity SCAN_INTERVAL = timedelta(seconds=3600) @@ -51,12 +49,12 @@ NUMBER_TYPE = EzvizNumberEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizNumber(coordinator, camera, value, entry.entry_id) diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index d6dc3dc8550..8e037fe6c33 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -8,14 +8,12 @@ from pyezviz.constants import DeviceSwitchType, SoundMode from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -38,12 +36,12 @@ SELECT_TYPE = EzvizSelectEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ select entities based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizSelect(coordinator, camera) diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index e0750b985fc..f3d50836bc7 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -7,13 +7,11 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -72,12 +70,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py index 8bacceff29f..5a612aa0772 100644 --- a/homeassistant/components/ezviz/siren.py +++ b/homeassistant/components/ezviz/siren.py @@ -13,16 +13,14 @@ from homeassistant.components.siren import ( SirenEntityDescription, SirenEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import event as evt from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from homeassistant.helpers.restore_state import RestoreEntity -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizBaseEntity PARALLEL_UPDATES = 1 @@ -35,12 +33,12 @@ SIREN_ENTITY_TYPE = SirenEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizSirenEntity(coordinator, camera, SIREN_ENTITY_TYPE) diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 65fb7b9f36b..1a347c931a6 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -13,13 +13,11 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity @@ -107,12 +105,12 @@ SWITCH_TYPES: dict[int, EzvizSwitchEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ switch based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizSwitch(coordinator, camera, switch_number) diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py index 25a506a0052..3027e048688 100644 --- a/homeassistant/components/ezviz/update.py +++ b/homeassistant/components/ezviz/update.py @@ -12,13 +12,11 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -30,12 +28,12 @@ UPDATE_ENTITY_TYPES = UpdateEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizUpdateEntity(coordinator, camera, sensor, UPDATE_ENTITY_TYPES) diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index 3319f6bdebd..edd46d24982 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py index 9e6d23556d2..d71d404c7a0 100644 --- a/homeassistant/components/fail2ban/sensor.py +++ b/homeassistant/components/fail2ban/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_FILE_PATH, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py index 462983278b0..6be13b23568 100644 --- a/homeassistant/components/familyhub/camera.py +++ b/homeassistant/components/familyhub/camera.py @@ -11,8 +11,8 @@ from homeassistant.components.camera import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 9faed54c041..31617cb220b 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -2,15 +2,12 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util.hass_dict import HassKey -from .const import CONF_MAX_ENTRIES, DOMAIN -from .coordinator import FeedReaderCoordinator, StoredData - -type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator] +from .const import DOMAIN +from .coordinator import FeedReaderConfigEntry, FeedReaderCoordinator, StoredData CONF_URLS = "urls" @@ -23,12 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) - if not storage.is_initialized: await storage.async_setup() - coordinator = FeedReaderCoordinator( - hass, - entry.data[CONF_URL], - entry.options[CONF_MAX_ENTRIES], - storage, - ) + coordinator = FeedReaderCoordinator(hass, entry, storage) await coordinator.async_setup() diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index f3e56ad1778..3d0fec1a6f5 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index fc338d63268..9901bd9f1b4 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -13,13 +13,14 @@ from urllib.error import URLError import feedparser from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER +from .const import CONF_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER DELAY_SAVE = 30 STORAGE_VERSION = 1 @@ -27,37 +28,39 @@ STORAGE_VERSION = 1 _LOGGER = getLogger(__name__) +type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator] + class FeedReaderCoordinator( DataUpdateCoordinator[list[feedparser.FeedParserDict] | None] ): """Abstraction over Feedparser module.""" - config_entry: ConfigEntry + config_entry: FeedReaderConfigEntry def __init__( self, hass: HomeAssistant, - url: str, - max_entries: int, + config_entry: FeedReaderConfigEntry, storage: StoredData, ) -> None: """Initialize the FeedManager object, poll as per scan interval.""" - super().__init__( - hass=hass, - logger=_LOGGER, - name=f"{DOMAIN} {url}", - update_interval=DEFAULT_SCAN_INTERVAL, - ) - self.url = url + self.url = config_entry.data[CONF_URL] self.feed_author: str | None = None self.feed_version: str | None = None - self._max_entries = max_entries + self._max_entries = config_entry.options[CONF_MAX_ENTRIES] self._storage = storage self._last_entry_timestamp: struct_time | None = None self._event_type = EVENT_FEEDREADER self._feed: feedparser.FeedParserDict | None = None - self._feed_id = url + self._feed_id = self.url + super().__init__( + hass=hass, + logger=_LOGGER, + config_entry=config_entry, + name=f"{DOMAIN} {self.url}", + update_interval=DEFAULT_SCAN_INTERVAL, + ) @callback def _log_no_entries(self) -> None: diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 6957702523f..fc5341b025e 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -17,7 +17,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index 2c46c4c29d1..03566ba162c 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -16,8 +16,8 @@ from homeassistant.components.camera import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py index 7dc32fd96a3..3adae8441df 100644 --- a/homeassistant/components/ffmpeg_motion/binary_sensor.py +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.ffmpeg import ( ) from homeassistant.const import CONF_NAME, CONF_REPEAT from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py index abbf77eba6b..1623d7c7660 100644 --- a/homeassistant/components/ffmpeg_noise/binary_sensor.py +++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py @@ -22,7 +22,7 @@ from homeassistant.components.ffmpeg import ( from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index bc6e6340111..86e81a596d7 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -28,8 +28,8 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 10e3d4a4ac6..3d61dbb04e0 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_FILE_PATH, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py index 8350cee91bf..0c2a0277434 100644 --- a/homeassistant/components/filesize/coordinator.py +++ b/homeassistant/components/filesize/coordinator.py @@ -9,7 +9,7 @@ import pathlib from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/filter/__init__.py b/homeassistant/components/filter/__init__.py index 7f3f6cbfffc..9a4f4913c9f 100644 --- a/homeassistant/components/filter/__init__.py +++ b/homeassistant/components/filter/__init__.py @@ -1,6 +1,25 @@ """The filter component.""" -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant -DOMAIN = "filter" -PLATFORMS = [Platform.SENSOR] +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Filter from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Filter config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/filter/config_flow.py b/homeassistant/components/filter/config_flow.py new file mode 100644 index 00000000000..dac2d8995bf --- /dev/null +++ b/homeassistant/components/filter/config_flow.py @@ -0,0 +1,243 @@ +"""Config flow for filter.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + DurationSelector, + DurationSelectorConfig, + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from .const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_FILTER_TIME_CONSTANT, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + TIME_SMA_LAST, +) + +FILTERS = [ + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, +] + + +async def get_next_step(user_input: dict[str, Any]) -> str: + """Return next step for options.""" + return cast(str, user_input[CONF_FILTER_NAME]) + + +async def validate_options( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate options selected.""" + + if CONF_FILTER_WINDOW_SIZE in user_input and isinstance( + user_input[CONF_FILTER_WINDOW_SIZE], float + ): + user_input[CONF_FILTER_WINDOW_SIZE] = int(user_input[CONF_FILTER_WINDOW_SIZE]) + if CONF_FILTER_TIME_CONSTANT in user_input: + user_input[CONF_FILTER_TIME_CONSTANT] = int( + user_input[CONF_FILTER_TIME_CONSTANT] + ) + if CONF_FILTER_PRECISION in user_input: + user_input[CONF_FILTER_PRECISION] = int(user_input[CONF_FILTER_PRECISION]) + + handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001 + + return user_input + + +DATA_SCHEMA_SETUP = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_ENTITY_ID): EntitySelector( + EntitySelectorConfig(domain=[SENSOR_DOMAIN]) + ), + vol.Required(CONF_FILTER_NAME): SelectSelector( + SelectSelectorConfig( + options=FILTERS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_FILTER_NAME, + ) + ), + } +) + +BASE_OPTIONS_SCHEMA = { + vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ) +} + +OUTLIER_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +LOWPASS_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional( + CONF_FILTER_TIME_CONSTANT, default=DEFAULT_FILTER_TIME_CONSTANT + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +RANGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FILTER_LOWER_BOUND): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_FILTER_UPPER_BOUND): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +TIME_SMA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TIME_SMA_TYPE, default=TIME_SMA_LAST): SelectSelector( + SelectSelectorConfig( + options=[TIME_SMA_LAST], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TIME_SMA_TYPE, + ) + ), + vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( + DurationSelectorConfig(enable_day=False, allow_negative=False) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +THROTTLE_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +TIME_THROTTLE_SCHEMA = vol.Schema( + { + vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( + DurationSelectorConfig(enable_day=False, allow_negative=False) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=DATA_SCHEMA_SETUP, + next_step=get_next_step, + ), + "lowpass": SchemaFlowFormStep( + schema=LOWPASS_SCHEMA, validate_user_input=validate_options + ), + "outlier": SchemaFlowFormStep( + schema=OUTLIER_SCHEMA, validate_user_input=validate_options + ), + "range": SchemaFlowFormStep( + schema=RANGE_SCHEMA, validate_user_input=validate_options + ), + "time_simple_moving_average": SchemaFlowFormStep( + schema=TIME_SMA_SCHEMA, validate_user_input=validate_options + ), + "throttle": SchemaFlowFormStep( + schema=THROTTLE_SCHEMA, validate_user_input=validate_options + ), + "time_throttle": SchemaFlowFormStep( + schema=TIME_THROTTLE_SCHEMA, validate_user_input=validate_options + ), +} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + schema=None, + next_step=get_next_step, + ), + "lowpass": SchemaFlowFormStep( + schema=LOWPASS_SCHEMA, validate_user_input=validate_options + ), + "outlier": SchemaFlowFormStep( + schema=OUTLIER_SCHEMA, validate_user_input=validate_options + ), + "range": SchemaFlowFormStep( + schema=RANGE_SCHEMA, validate_user_input=validate_options + ), + "time_simple_moving_average": SchemaFlowFormStep( + schema=TIME_SMA_SCHEMA, validate_user_input=validate_options + ), + "throttle": SchemaFlowFormStep( + schema=THROTTLE_SCHEMA, validate_user_input=validate_options + ), + "time_throttle": SchemaFlowFormStep( + schema=TIME_THROTTLE_SCHEMA, validate_user_input=validate_options + ), +} + + +class FilterConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Filter.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/filter/const.py b/homeassistant/components/filter/const.py new file mode 100644 index 00000000000..92d2498528e --- /dev/null +++ b/homeassistant/components/filter/const.py @@ -0,0 +1,36 @@ +"""The filter component constants.""" + +from homeassistant.const import Platform + +DOMAIN = "filter" +PLATFORMS = [Platform.SENSOR] + +CONF_INDEX = "index" + +FILTER_NAME_RANGE = "range" +FILTER_NAME_LOWPASS = "lowpass" +FILTER_NAME_OUTLIER = "outlier" +FILTER_NAME_THROTTLE = "throttle" +FILTER_NAME_TIME_THROTTLE = "time_throttle" +FILTER_NAME_TIME_SMA = "time_simple_moving_average" + +CONF_FILTERS = "filters" +CONF_FILTER_NAME = "filter" +CONF_FILTER_WINDOW_SIZE = "window_size" +CONF_FILTER_PRECISION = "precision" +CONF_FILTER_RADIUS = "radius" +CONF_FILTER_TIME_CONSTANT = "time_constant" +CONF_FILTER_LOWER_BOUND = "lower_bound" +CONF_FILTER_UPPER_BOUND = "upper_bound" +CONF_TIME_SMA_TYPE = "type" + +TIME_SMA_LAST = "last" + +WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 +WINDOW_SIZE_UNIT_TIME = 2 + +DEFAULT_NAME = "Filtered sensor" +DEFAULT_WINDOW_SIZE = 1 +DEFAULT_PRECISION = 2 +DEFAULT_FILTER_RADIUS = 2.0 +DEFAULT_FILTER_TIME_CONSTANT = 10 diff --git a/homeassistant/components/filter/manifest.json b/homeassistant/components/filter/manifest.json index 4d9a8992036..392351a235d 100644 --- a/homeassistant/components/filter/manifest.json +++ b/homeassistant/components/filter/manifest.json @@ -2,6 +2,7 @@ "domain": "filter", "name": "Filter", "codeowners": ["@dgomes"], + "config_flow": true, "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/filter", "integration_type": "helper", diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 549d74ffd09..330e61f499e 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -24,6 +24,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -42,48 +43,46 @@ from homeassistant.core import ( State, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.util import dt as dt_util from homeassistant.util.decorator import Registry -import homeassistant.util.dt as dt_util -from . import DOMAIN, PLATFORMS +from .const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_FILTERS, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_FILTER_TIME_CONSTANT, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + PLATFORMS, + TIME_SMA_LAST, + WINDOW_SIZE_UNIT_NUMBER_EVENTS, + WINDOW_SIZE_UNIT_TIME, +) _LOGGER = logging.getLogger(__name__) -FILTER_NAME_RANGE = "range" -FILTER_NAME_LOWPASS = "lowpass" -FILTER_NAME_OUTLIER = "outlier" -FILTER_NAME_THROTTLE = "throttle" -FILTER_NAME_TIME_THROTTLE = "time_throttle" -FILTER_NAME_TIME_SMA = "time_simple_moving_average" FILTERS: Registry[str, type[Filter]] = Registry() -CONF_FILTERS = "filters" -CONF_FILTER_NAME = "filter" -CONF_FILTER_WINDOW_SIZE = "window_size" -CONF_FILTER_PRECISION = "precision" -CONF_FILTER_RADIUS = "radius" -CONF_FILTER_TIME_CONSTANT = "time_constant" -CONF_FILTER_LOWER_BOUND = "lower_bound" -CONF_FILTER_UPPER_BOUND = "upper_bound" -CONF_TIME_SMA_TYPE = "type" - -TIME_SMA_LAST = "last" - -WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 -WINDOW_SIZE_UNIT_TIME = 2 - -DEFAULT_WINDOW_SIZE = 1 -DEFAULT_PRECISION = 2 -DEFAULT_FILTER_RADIUS = 2.0 -DEFAULT_FILTER_TIME_CONSTANT = 10 - -NAME_TEMPLATE = "{} filter" ICON = "mdi:chart-line-variant" FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)}) @@ -199,6 +198,32 @@ async def async_setup_platform( async_add_entities([SensorFilter(name, unique_id, entity_id, filters)]) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Filter sensor entry.""" + name: str = entry.options[CONF_NAME] + entity_id: str = entry.options[CONF_ENTITY_ID] + + filter_config = { + k: v for k, v in entry.options.items() if k not in (CONF_NAME, CONF_ENTITY_ID) + } + if CONF_FILTER_WINDOW_SIZE in filter_config and isinstance( + filter_config[CONF_FILTER_WINDOW_SIZE], dict + ): + filter_config[CONF_FILTER_WINDOW_SIZE] = timedelta( + **filter_config[CONF_FILTER_WINDOW_SIZE] + ) + + filters = [ + FILTERS[filter_config.pop(CONF_FILTER_NAME)](entity=entity_id, **filter_config) + ] + + async_add_entities([SensorFilter(name, entry.entry_id, entity_id, filters)]) + + class SensorFilter(SensorEntity): """Representation of a Filter Sensor.""" diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json index 2a83a05bb96..b0403227fd4 100644 --- a/homeassistant/components/filter/strings.json +++ b/homeassistant/components/filter/strings.json @@ -1,5 +1,197 @@ { "title": "Filter", + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "user": { + "description": "Add a filter sensor. UI configuration is limited to a single filter, use YAML for filter chain.", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "Entity", + "filter": "Filter" + }, + "data_description": { + "name": "Name for the created entity.", + "entity_id": "Entity to filter from.", + "filter": "Select filter to configure." + } + }, + "outlier": { + "description": "Read the documentation for further details on how to configure the filter sensor using these options.", + "data": { + "window_size": "Window size", + "precision": "Precision", + "radius": "Radius" + }, + "data_description": { + "window_size": "Size of the window of previous states.", + "precision": "Defines the number of decimal places of the calculated sensor value.", + "radius": "Band radius from median of previous states." + } + }, + "lowpass": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "time_constant": "Time constant" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "time_constant": "Loosely relates to the amount of time it takes for a state to influence the output." + } + }, + "range": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "lower_bound": "Lower bound", + "upper_bound": "Upper bound" + }, + "data_description": { + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "lower_bound": "Lower bound for filter range.", + "upper_bound": "Upper bound for filter range." + } + }, + "time_simple_moving_average": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "type": "Type" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "type": "Defines the type of Simple Moving Average." + } + }, + "throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + }, + "time_throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + } + } + }, + "options": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "outlier": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "radius": "[%key:component::filter::config::step::outlier::data::radius%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "radius": "[%key:component::filter::config::step::outlier::data_description::radius%]" + } + }, + "lowpass": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "time_constant": "[%key:component::filter::config::step::lowpass::data::time_constant%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "time_constant": "[%key:component::filter::config::step::lowpass::data_description::time_constant%]" + } + }, + "range": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "lower_bound": "[%key:component::filter::config::step::range::data::lower_bound%]", + "upper_bound": "[%key:component::filter::config::step::range::data::upper_bound%]" + }, + "data_description": { + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "lower_bound": "[%key:component::filter::config::step::range::data_description::lower_bound%]", + "upper_bound": "[%key:component::filter::config::step::range::data_description::upper_bound%]" + } + }, + "time_simple_moving_average": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "type": "[%key:component::filter::config::step::time_simple_moving_average::data::type%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "type": "[%key:component::filter::config::step::time_simple_moving_average::data_description::type%]" + } + }, + "throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + }, + "time_throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + } + } + }, + "selector": { + "filter": { + "options": { + "range": "Range", + "lowpass": "Lowpass", + "outlier": "Outlier", + "throttle": "Throttle", + "time_throttle": "Time throttle", + "time_simple_moving_average": "Moving Average (Time based)" + } + }, + "type": { + "options": { + "last": "Last" + } + } + }, "services": { "reload": { "name": "[%key:common::action::reload%]", diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index c85f08ba3d0..318325dbb09 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_PIN, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fivem/config_flow.py b/homeassistant/components/fivem/config_flow.py index b5ced70b846..d5132627b9d 100644 --- a/homeassistant/components/fivem/config_flow.py +++ b/homeassistant/components/fivem/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json index fd58922a481..f925a625259 100644 --- a/homeassistant/components/fivem/strings.json +++ b/homeassistant/components/fivem/strings.json @@ -14,7 +14,7 @@ }, "error": { "cannot_connect": "Failed to connect. Please check the host and port and try again. Also ensure that you are running the latest FiveM server.", - "invalid_game_name": "The api of the game you are trying to connect to is not a FiveM game.", + "invalid_game_name": "The API of the game you are trying to connect to is not a FiveM game.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index f8b4546d4c7..3fb241208ad 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_TARGET from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fleetgo/device_tracker.py b/homeassistant/components/fleetgo/device_tracker.py index 008c0765c07..71f6c174dde 100644 --- a/homeassistant/components/fleetgo/device_tracker.py +++ b/homeassistant/components/fleetgo/device_tracker.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_utc_time_change from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index 8be5df4eca7..32c94638b1f 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py index cd160480674..281e960f222 100644 --- a/homeassistant/components/flic/binary_sensor.py +++ b/homeassistant/components/flic/binary_sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py index 0edb80004fd..d0dd38bd490 100644 --- a/homeassistant/components/flo/coordinator.py +++ b/homeassistant/components/flo/coordinator.py @@ -12,7 +12,7 @@ from orjson import JSONDecodeError from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN as FLO_DOMAIN, LOGGER diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index 811ee51749c..f50e04cba36 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -14,8 +14,8 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index ca7fb7aeea2..2a0b5795970 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -25,8 +25,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_EFFECT from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py index 3a8a4fdc380..4667a6c348d 100644 --- a/homeassistant/components/folder/sensor.py +++ b/homeassistant/components/folder/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index af2bc92a065..4360dd031c7 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -19,7 +19,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_TOKEN, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py index 12a29fd632e..25effac073d 100644 --- a/homeassistant/components/foursquare/__init__.py +++ b/homeassistant/components/foursquare/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/free_mobile/notify.py b/homeassistant/components/free_mobile/notify.py index 90c8ef3246e..c7e3071c771 100644 --- a/homeassistant/components/free_mobile/notify.py +++ b/homeassistant/components/free_mobile/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 097c8c138ee..588992a7f21 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .entity import FreeboxHomeEntity diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index 5338c0d0700..460ad163f61 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -9,8 +9,8 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 1e1830ca1c1..05a2a07707f 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -2,7 +2,6 @@ import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -12,26 +11,36 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( - DATA_FRITZ, DEFAULT_SSL, DOMAIN, FRITZ_AUTH_EXCEPTIONS, FRITZ_EXCEPTIONS, PLATFORMS, ) -from .coordinator import AvmWrapper, FritzData -from .services import async_setup_services, async_unload_services +from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up fritzboxtools integration.""" + await async_setup_services(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bool: """Set up fritzboxtools from config entry.""" _LOGGER.debug("Setting up FRITZ!Box Tools component") avm_wrapper = AvmWrapper( hass=hass, + config_entry=entry, host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], username=entry.data[CONF_USERNAME], @@ -54,42 +63,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await avm_wrapper.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = avm_wrapper + entry.runtime_data = avm_wrapper - if DATA_FRITZ not in hass.data: - hass.data[DATA_FRITZ] = FritzData() + if FRITZ_DATA_KEY not in hass.data: + hass.data[FRITZ_DATA_KEY] = FritzData() entry.async_on_unload(entry.add_update_listener(update_listener)) # Load the other platforms like switch await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await async_setup_services(hass) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bool: """Unload FRITZ!Box Tools config entry.""" - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data - fritz_data = hass.data[DATA_FRITZ] + fritz_data = hass.data[FRITZ_DATA_KEY] fritz_data.tracked.pop(avm_wrapper.unique_id) if not bool(fritz_data.tracked): - hass.data.pop(DATA_FRITZ) + hass.data.pop(FRITZ_DATA_KEY) - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - await async_unload_services(hass) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: FritzConfigEntry) -> None: """Update when config_entry options update.""" if entry.options: await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index cb1f698bdca..7553328a64c 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -11,13 +11,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import AvmWrapper, ConnectionInfo +from .coordinator import ConnectionInfo, FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) @@ -51,11 +49,13 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up FRITZ!Box binary sensors") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data connection_info = await avm_wrapper.async_get_connection_info() diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 263521d23f4..f3ffbe42099 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -12,15 +12,21 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback 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 .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DATA_FRITZ, DOMAIN, MeshRoles -from .coordinator import AvmWrapper, FritzData, FritzDevice, _is_tracked +from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles +from .coordinator import ( + FRITZ_DATA_KEY, + AvmWrapper, + FritzConfigEntry, + FritzData, + FritzDevice, + _is_tracked, +) from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) @@ -65,12 +71,12 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FritzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set buttons for device.""" _LOGGER.debug("Setting up buttons") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data entities_list: list[ButtonEntity] = [ FritzButton(avm_wrapper, entry.title, button) for button in BUTTONS @@ -80,7 +86,7 @@ async def async_setup_entry( async_add_entities(entities_list) return - data_fritz: FritzData = hass.data[DATA_FRITZ] + data_fritz = hass.data[FRITZ_DATA_KEY] entities_list += _async_wol_buttons_list(avm_wrapper, data_fritz) async_add_entities(entities_list) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 7b6057b3ba2..fb17f872cb6 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -17,12 +17,7 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -53,6 +48,7 @@ from .const import ( ERROR_UPNP_NOT_CONFIGURED, FRITZ_AUTH_EXCEPTIONS, ) +from .coordinator import FritzConfigEntry _LOGGER = logging.getLogger(__name__) @@ -67,7 +63,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: FritzConfigEntry, ) -> FritzBoxToolsOptionsFlowHandler: """Get the options flow for this handler.""" return FritzBoxToolsOptionsFlowHandler() @@ -116,7 +112,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): return None - async def async_check_configured_entry(self) -> ConfigEntry | None: + async def async_check_configured_entry(self) -> FritzConfigEntry | None: """Check if entry is configured.""" current_host = await self.hass.async_add_executor_job( socket.gethostbyname, self._host diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 9a266507c25..2237823bc3b 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -40,8 +40,6 @@ PLATFORMS = [ CONF_OLD_DISCOVERY = "old_discovery" DEFAULT_CONF_OLD_DISCOVERY = False -DATA_FRITZ = "fritz_data" - DSL_CONNECTION: Literal["dsl"] = "dsl" DEFAULT_DEVICE_NAME = "Unknown device" @@ -56,9 +54,6 @@ ERROR_CANNOT_CONNECT = "cannot_connect" ERROR_UPNP_NOT_CONFIGURED = "upnp_not_configured" ERROR_UNKNOWN = "unknown_error" -FRITZ_SERVICES = "fritz_services" -SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password" - SWITCH_TYPE_DEFLECTION = "CallDeflection" SWITCH_TYPE_PORTFORWARD = "PortForward" SWITCH_TYPE_PROFILE = "Profile" diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 52bff67c229..38d76c92871 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -16,11 +16,10 @@ from fritzconnection.core.exceptions import ( FritzActionError, FritzConnectionException, FritzSecurityError, - FritzServiceError, ) from fritzconnection.lib.fritzhosts import FritzHosts from fritzconnection.lib.fritzstatus import FritzStatus -from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH, FritzGuestWLAN +from fritzconnection.lib.fritzwlan import FritzGuestWLAN import xmltodict from homeassistant.components.device_tracker import ( @@ -29,7 +28,7 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -37,6 +36,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from .const import ( CONF_OLD_DISCOVERY, @@ -46,14 +46,17 @@ from .const import ( DEFAULT_USERNAME, DOMAIN, FRITZ_EXCEPTIONS, - SERVICE_SET_GUEST_WIFI_PW, MeshRoles, ) _LOGGER = logging.getLogger(__name__) +FRITZ_DATA_KEY: HassKey[FritzData] = HassKey(DOMAIN) -def _is_tracked(mac: str, current_devices: ValuesView) -> bool: +type FritzConfigEntry = ConfigEntry[AvmWrapper] + + +def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool: """Check if device is already tracked.""" return any(mac in tracked for tracked in current_devices) @@ -61,7 +64,7 @@ def _is_tracked(mac: str, current_devices: ValuesView) -> bool: def device_filter_out_from_trackers( mac: str, device: FritzDevice, - current_devices: ValuesView, + current_devices: ValuesView[set[str]], ) -> bool: """Check if device should be filtered out from trackers.""" reason: str | None = None @@ -162,11 +165,12 @@ class UpdateCoordinatorDataType(TypedDict): class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): """FritzBoxTools class.""" - config_entry: ConfigEntry + config_entry: FritzConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: FritzConfigEntry, password: str, port: int, username: str = DEFAULT_USERNAME, @@ -176,6 +180,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Initialize FritzboxTools class.""" super().__init__( hass=hass, + config_entry=config_entry, logger=_LOGGER, name=f"{DOMAIN}-{host}-coordinator", update_interval=timedelta(seconds=30), @@ -693,34 +698,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): device.id, remove_config_entry_id=config_entry.entry_id ) - async def service_fritzbox( - self, service_call: ServiceCall, config_entry: ConfigEntry - ) -> None: - """Define FRITZ!Box services.""" - _LOGGER.debug("FRITZ!Box service: %s", service_call.service) - - if not self.connection: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="unable_to_connect" - ) - - try: - if service_call.service == SERVICE_SET_GUEST_WIFI_PW: - await self.async_trigger_set_guest_password( - service_call.data.get("password"), - service_call.data.get("length", DEFAULT_PASSWORD_LENGTH), - ) - return - - except (FritzServiceError, FritzActionError) as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="service_parameter_unknown" - ) from ex - except FritzConnectionException as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="service_not_supported" - ) from ex - class AvmWrapper(FritzBoxTools): """Setup AVM wrapper for API calls.""" @@ -899,9 +876,9 @@ class AvmWrapper(FritzBoxTools): class FritzData: """Storage class for platform global data.""" - tracked: dict = field(default_factory=dict) - profile_switches: dict = field(default_factory=dict) - wol_buttons: dict = field(default_factory=dict) + tracked: dict[str, set[str]] = field(default_factory=dict) + profile_switches: dict[str, set[str]] = field(default_factory=dict) + wol_buttons: dict[str, set[str]] = field(default_factory=dict) class FritzDevice: diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index d1270a0510c..ba3c9a5aab6 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -6,14 +6,14 @@ import datetime import logging from homeassistant.components.device_tracker import ScannerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_FRITZ, DOMAIN from .coordinator import ( + FRITZ_DATA_KEY, AvmWrapper, + FritzConfigEntry, FritzData, FritzDevice, device_filter_out_from_trackers, @@ -24,12 +24,14 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for FRITZ!Box component.""" _LOGGER.debug("Starting FRITZ!Box device tracker") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] - data_fritz: FritzData = hass.data[DATA_FRITZ] + avm_wrapper = entry.runtime_data + data_fritz = hass.data[FRITZ_DATA_KEY] @callback def update_avm_device() -> None: diff --git a/homeassistant/components/fritz/diagnostics.py b/homeassistant/components/fritz/diagnostics.py index 8823d55baa9..b9ae9edf04d 100644 --- a/homeassistant/components/fritz/diagnostics.py +++ b/homeassistant/components/fritz/diagnostics.py @@ -5,21 +5,19 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import AvmWrapper +from .coordinator import FritzConfigEntry TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: FritzConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index 19c98446ccd..d305551b097 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -8,14 +8,12 @@ import logging from requests.exceptions import RequestException from homeassistant.components.image import ImageEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util, slugify -from .const import DOMAIN -from .coordinator import AvmWrapper +from .coordinator import AvmWrapper, FritzConfigEntry from .entity import FritzBoxBaseEntity _LOGGER = logging.getLogger(__name__) @@ -23,11 +21,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FritzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up guest WiFi QR code for device.""" - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data guest_wifi_info = await hass.async_add_executor_job( avm_wrapper.fritz_guest_wifi.get_info diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 06c572f93a6..805705eb4b4 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -1,8 +1,6 @@ rules: # Bronze - action-setup: - status: todo - comment: still in async_setup_entry, needs to be moved to async_setup + action-setup: done appropriate-polling: done brands: done common-modules: done @@ -24,9 +22,7 @@ rules: has-entity-name: status: todo comment: partially done - runtime-data: - status: todo - comment: still uses hass.data + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 11ee0ad5510..81b50bd21ac 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, EntityCategory, @@ -27,8 +26,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION -from .coordinator import AvmWrapper, ConnectionInfo +from .const import DSL_CONNECTION, UPTIME_DEVIATION +from .coordinator import ConnectionInfo, FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) @@ -267,11 +266,13 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up FRITZ!Box sensors") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data connection_info = await avm_wrapper.async_get_connection_info() diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index bace7480ba5..02e6c91f4bf 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -1,21 +1,25 @@ """Services for Fritz integration.""" -from __future__ import annotations - import logging +from fritzconnection.core.exceptions import ( + FritzActionError, + FritzConnectionException, + FritzServiceError, +) +from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH import voluptuous as vol -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_extract_config_entry_ids -from .const import DOMAIN, FRITZ_SERVICES, SERVICE_SET_GUEST_WIFI_PW -from .coordinator import AvmWrapper +from .const import DOMAIN +from .coordinator import FritzConfigEntry _LOGGER = logging.getLogger(__name__) +SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password" SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema( { vol.Required("device_id"): str, @@ -24,71 +28,48 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema( } ) -SERVICE_LIST: list[tuple[str, vol.Schema | None]] = [ - (SERVICE_SET_GUEST_WIFI_PW, SERVICE_SCHEMA_SET_GUEST_WIFI_PW), -] + +async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None: + """Call Fritz set guest wifi password service.""" + hass = service_call.hass + target_entry_ids = await async_extract_config_entry_ids(hass, service_call) + target_entries: list[FritzConfigEntry] = [ + loaded_entry + for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) + if loaded_entry.entry_id in target_entry_ids + ] + + if not target_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={"service": service_call.service}, + ) + + for target_entry in target_entries: + _LOGGER.debug("Executing service %s", service_call.service) + avm_wrapper = target_entry.runtime_data + try: + await avm_wrapper.async_trigger_set_guest_password( + service_call.data.get("password"), + service_call.data.get("length", DEFAULT_PASSWORD_LENGTH), + ) + except (FritzServiceError, FritzActionError) as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="service_parameter_unknown" + ) from ex + except FritzConnectionException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="service_not_supported" + ) from ex async def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Fritz integration.""" - for service, _ in SERVICE_LIST: - if hass.services.has_service(DOMAIN, service): - return - - async def async_call_fritz_service(service_call: ServiceCall) -> None: - """Call correct Fritz service.""" - - if not ( - fritzbox_entry_ids := await _async_get_configured_avm_device( - hass, service_call - ) - ): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="config_entry_not_found", - translation_placeholders={"service": service_call.service}, - ) - - for entry_id in fritzbox_entry_ids: - _LOGGER.debug("Executing service %s", service_call.service) - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry_id] - if config_entry := hass.config_entries.async_get_entry(entry_id): - await avm_wrapper.service_fritzbox(service_call, config_entry) - else: - _LOGGER.error( - "Executing service %s failed, no config entry found", - service_call.service, - ) - - for service, schema in SERVICE_LIST: - hass.services.async_register(DOMAIN, service, async_call_fritz_service, schema) - - -async def _async_get_configured_avm_device( - hass: HomeAssistant, service_call: ServiceCall -) -> list: - """Get FritzBoxTools class from config entry.""" - - list_entry_id: list = [] - for entry_id in await async_extract_config_entry_ids(hass, service_call): - config_entry = hass.config_entries.async_get_entry(entry_id) - if ( - config_entry - and config_entry.domain == DOMAIN - and config_entry.state == ConfigEntryState.LOADED - ): - list_entry_id.append(entry_id) - return list_entry_id - - -async def async_unload_services(hass: HomeAssistant) -> None: - """Unload services for Fritz integration.""" - - if not hass.data.get(FRITZ_SERVICES): - return - - hass.data[FRITZ_SERVICES] = False - - for service, _ in SERVICE_LIST: - hass.services.async_remove(DOMAIN, service) + hass.services.async_register( + DOMAIN, + SERVICE_SET_GUEST_WIFI_PW, + _async_set_guest_wifi_password, + SERVICE_SCHEMA_SET_GUEST_WIFI_PW, + ) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 372af89cc9e..9c12fe0cecc 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -7,7 +7,6 @@ from typing import Any from homeassistant.components.network import async_get_source_ip 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.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -18,7 +17,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from .const import ( - DATA_FRITZ, DOMAIN, SWITCH_TYPE_DEFLECTION, SWITCH_TYPE_PORTFORWARD, @@ -28,7 +26,9 @@ from .const import ( MeshRoles, ) from .coordinator import ( + FRITZ_DATA_KEY, AvmWrapper, + FritzConfigEntry, FritzData, FritzDevice, SwitchInfo, @@ -220,12 +220,14 @@ async def async_all_entities_list( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up switches") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] - data_fritz: FritzData = hass.data[DATA_FRITZ] + avm_wrapper = entry.runtime_data + data_fritz = hass.data[FRITZ_DATA_KEY] _LOGGER.debug("Fritzbox services: %s", avm_wrapper.connection.services) diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 6969f201f27..ad23a076ca6 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -11,13 +11,11 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -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 AvmWrapper +from .coordinator import AvmWrapper, FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) @@ -29,11 +27,13 @@ class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescripti async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up AVM FRITZ!Box update entities.""" _LOGGER.debug("Setting up AVM FRITZ!Box update entities") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data entities = [FritzBoxUpdateEntity(avm_wrapper, entry.title)] diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 07bc8fb15f2..afe6f1abba8 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) -> await async_migrate_entries(hass, entry.entry_id, _update_unique_id) - coordinator = FritzboxDataUpdateCoordinator(hass, entry.entry_id) + coordinator = FritzboxDataUpdateCoordinator(hass, entry) await coordinator.async_setup() entry.runtime_data = coordinator diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index d5a81fdef1a..87a87ac691f 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -141,7 +141,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): await self.async_set_hvac_mode(hvac_mode) elif target_temp is not None: await self.hass.async_add_executor_job( - self.data.set_target_temperature, target_temp + self.data.set_target_temperature, target_temp, True ) else: return diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index a6a30ffdc6a..34df3885deb 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -38,12 +38,13 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat fritz: Fritzhome has_templates: bool - def __init__(self, hass: HomeAssistant, name: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None: """Initialize the Fritzbox Smarthome device coordinator.""" super().__init__( hass, LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.entry_id, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index de87d6f8852..070bb868298 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -71,21 +71,21 @@ class FritzboxCover(FritzBoxDeviceEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self.hass.async_add_executor_job(self.data.set_blind_open) + await self.hass.async_add_executor_job(self.data.set_blind_open, True) await self.coordinator.async_refresh() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self.hass.async_add_executor_job(self.data.set_blind_close) + await self.hass.async_add_executor_job(self.data.set_blind_close, True) await self.coordinator.async_refresh() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self.hass.async_add_executor_job( - self.data.set_level_percentage, 100 - kwargs[ATTR_POSITION] + self.data.set_level_percentage, 100 - kwargs[ATTR_POSITION], True ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self.hass.async_add_executor_job(self.data.set_blind_stop) + await self.hass.async_add_executor_job(self.data.set_blind_stop, True) await self.coordinator.async_refresh() diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index 36cb7dc8cff..94d7d320704 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -4,8 +4,6 @@ from __future__ import annotations from typing import Any, cast -from requests.exceptions import HTTPError - from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -122,26 +120,24 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Turn the light on.""" if kwargs.get(ATTR_BRIGHTNESS) is not None: level = kwargs[ATTR_BRIGHTNESS] - await self.hass.async_add_executor_job(self.data.set_level, level) + await self.hass.async_add_executor_job(self.data.set_level, level, True) if kwargs.get(ATTR_HS_COLOR) is not None: - # Try setunmappedcolor first. This allows free color selection, - # but we don't know if its supported by all devices. - try: - # HA gives 0..360 for hue, fritz light only supports 0..359 - unmapped_hue = int(kwargs[ATTR_HS_COLOR][0] % 360) - unmapped_saturation = round( - cast(float, kwargs[ATTR_HS_COLOR][1]) * 255.0 / 100.0 - ) + # HA gives 0..360 for hue, fritz light only supports 0..359 + unmapped_hue = int(kwargs[ATTR_HS_COLOR][0] % 360) + unmapped_saturation = round( + cast(float, kwargs[ATTR_HS_COLOR][1]) * 255.0 / 100.0 + ) + if self.data.fullcolorsupport: + LOGGER.debug("device has fullcolorsupport, using 'setunmappedcolor'") await self.hass.async_add_executor_job( - self.data.set_unmapped_color, (unmapped_hue, unmapped_saturation) + self.data.set_unmapped_color, + (unmapped_hue, unmapped_saturation), + 0, + True, ) - # This will raise 400 BAD REQUEST if the setunmappedcolor is not available - except HTTPError as err: - if err.response.status_code != 400: - raise + else: LOGGER.debug( - "fritzbox does not support method 'setunmappedcolor', fallback to" - " 'setcolor'" + "device has no fullcolorsupport, using supported colors with 'setcolor'" ) # find supported hs values closest to what user selected hue = min( @@ -152,18 +148,18 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): key=lambda x: abs(x - unmapped_saturation), ) await self.hass.async_add_executor_job( - self.data.set_color, (hue, saturation) + self.data.set_color, (hue, saturation), 0, True ) if kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None: await self.hass.async_add_executor_job( - self.data.set_color_temp, kwargs[ATTR_COLOR_TEMP_KELVIN] + self.data.set_color_temp, kwargs[ATTR_COLOR_TEMP_KELVIN], 0, True ) - await self.hass.async_add_executor_job(self.data.set_state_on) + await self.hass.async_add_executor_job(self.data.set_state_on, True) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self.hass.async_add_executor_job(self.data.set_state_off) + await self.hass.async_add_executor_job(self.data.set_state_off, True) await self.coordinator.async_refresh() diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 18b676d449e..d83793c77dc 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -51,13 +51,13 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self.check_lock_state() - await self.hass.async_add_executor_job(self.data.set_switch_state_on) + await self.hass.async_add_executor_job(self.data.set_switch_state_on, True) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self.check_lock_state() - await self.hass.async_add_executor_job(self.data.set_switch_state_off) + await self.hass.async_add_executor_job(self.data.set_switch_state_off, True) await self.coordinator.async_refresh() def check_lock_state(self) -> None: diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 050d57fc358..6184d888004 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,8 +26,7 @@ from homeassistant.const import ( EVENT_THEMES_UPDATED, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import service -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted from homeassistant.helpers.storage import Store diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2724569d1ed..b545026059c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250109.2"] + "requirements": ["home-assistant-frontend==20250130.0"] } diff --git a/homeassistant/components/fully_kiosk/image.py b/homeassistant/components/fully_kiosk/image.py index 00318a77ab5..e1a4240c9e9 100644 --- a/homeassistant/components/fully_kiosk/image.py +++ b/homeassistant/components/fully_kiosk/image.py @@ -12,7 +12,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index 089ae1d4246..ac6faf76a9d 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -8,8 +8,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( ATTR_APPLICATION, diff --git a/homeassistant/components/futurenow/light.py b/homeassistant/components/futurenow/light.py index d1ad6f42083..e9dcfd7a151 100644 --- a/homeassistant/components/futurenow/light.py +++ b/homeassistant/components/futurenow/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index 77724e3f673..ab4a74c627a 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -25,6 +25,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.IMAGE, Platform.SENSOR, ] type FytaConfigEntry = ConfigEntry[FytaCoordinator] diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index 553960bdcc6..a0c42d449d5 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -19,7 +19,7 @@ from fyta_cli.fyta_models import Plant from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_EXPIRATION, DOMAIN diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py new file mode 100644 index 00000000000..f03df969dcc --- /dev/null +++ b/homeassistant/components/fyta/image.py @@ -0,0 +1,64 @@ +"""Entity for Fyta plant image.""" + +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FytaConfigEntry +from .coordinator import FytaCoordinator +from .entity import FytaPlantEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the FYTA plant images.""" + coordinator = entry.runtime_data + + description = ImageEntityDescription(key="plant_image") + + async_add_entities( + FytaPlantImageEntity(coordinator, entry, description, plant_id) + for plant_id in coordinator.fyta.plant_list + if plant_id in coordinator.data + ) + + def _async_add_new_device(plant_id: int) -> None: + async_add_entities( + [FytaPlantImageEntity(coordinator, entry, description, plant_id)] + ) + + coordinator.new_device_callbacks.append(_async_add_new_device) + + +class FytaPlantImageEntity(FytaPlantEntity, ImageEntity): + """Represents a Fyta image.""" + + entity_description: ImageEntityDescription + + def __init__( + self, + coordinator: FytaCoordinator, + entry: ConfigEntry, + description: ImageEntityDescription, + plant_id: int, + ) -> None: + """Initiatlize Fyta Image entity.""" + super().__init__(coordinator, entry, description, plant_id) + ImageEntity.__init__(self, coordinator.hass) + + self._attr_name = None + + @property + def image_url(self) -> str: + """Return the image_url for this sensor.""" + image = self.plant.plant_origin_path + if image != self._attr_image_url: + self._attr_image_last_updated = datetime.now() + + return image diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 82045e91321..ef11038aee4 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_utc_time_change from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 7aae629974c..47034e61fb9 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import DeviceUnavailable, GardenaBluetoothCoordinator diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index ee8a2663218..c07d2ba6866 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import GardenaBluetoothConfigEntry from .coordinator import GardenaBluetoothCoordinator diff --git a/homeassistant/components/gc100/__init__.py b/homeassistant/components/gc100/__init__.py index 57c8e92499f..a43741b9249 100644 --- a/homeassistant/components/gc100/__init__.py +++ b/homeassistant/components/gc100/__init__.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType CONF_PORTS = "ports" diff --git a/homeassistant/components/gc100/binary_sensor.py b/homeassistant/components/gc100/binary_sensor.py index 55df72cc3b9..cef798935cb 100644 --- a/homeassistant/components/gc100/binary_sensor.py +++ b/homeassistant/components/gc100/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/gc100/switch.py b/homeassistant/components/gc100/switch.py index 1bcdc7365cf..23b178cc647 100644 --- a/homeassistant/components/gc100/switch.py +++ b/homeassistant/components/gc100/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/geniushub/entity.py b/homeassistant/components/geniushub/entity.py index 7c40c41bda5..24917ab5e95 100644 --- a/homeassistant/components/geniushub/entity.py +++ b/homeassistant/components/geniushub/entity.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ATTR_DURATION, ATTR_ZONE_MODE, DOMAIN, SVC_SET_ZONE_OVERRIDE diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index cfe4107428c..a558ad18672 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import GeniusHubConfigEntry from .entity import GeniusDevice, GeniusEntity diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index 0dc8918b7dd..079a47a6c27 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index d38514fc412..46a3482ce1e 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 9977f9d84cc..17338119b9f 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -23,11 +23,11 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) -import homeassistant.helpers.config_validation as cv from .const import CLIENT_ID, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, LOGGER diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py index 6ed3112b2af..933ba0e482e 100644 --- a/homeassistant/components/gitlab_ci/sensor.py +++ b/homeassistant/components/gitlab_ci/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index bc444655908..957ac4e9d8c 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_ROOM from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 03912c9a1ec..5a88ac612da 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -37,7 +37,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE_INFO, KEY_INVERTER from .coordinator import GoodweUpdateCoordinator diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 2ad400aabab..2b7aeadc0ba 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -28,9 +28,8 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id from .api import ApiAuthImpl, get_feature_access diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py index f1adc42b4cd..f71ccea00cc 100644 --- a/homeassistant/components/google_cloud/helpers.py +++ b/homeassistant/components/google_cloud/helpers.py @@ -12,7 +12,7 @@ from google.oauth2.service_account import Credentials import voluptuous as vol from homeassistant.components.tts import CONF_LANG -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py new file mode 100644 index 00000000000..af93956931a --- /dev/null +++ b/homeassistant/components/google_drive/__init__.py @@ -0,0 +1,65 @@ +"""The Google Drive integration.""" + +from __future__ import annotations + +from collections.abc import Callable + +from google_drive_api.exceptions import GoogleDriveApiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import instance_id +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +from homeassistant.util.hass_dict import HassKey + +from .api import AsyncConfigEntryAuth, DriveClient +from .const import DOMAIN + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) + + +type GoogleDriveConfigEntry = ConfigEntry[DriveClient] + + +async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool: + """Set up Google Drive from a config entry.""" + auth = AsyncConfigEntryAuth( + async_get_clientsession(hass), + OAuth2Session( + hass, entry, await async_get_config_entry_implementation(hass, entry) + ), + ) + + # Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not + await auth.async_get_access_token() + + client = DriveClient(await instance_id.async_get(hass), auth) + entry.runtime_data = client + + # Test we can access Google Drive and raise if not + try: + await client.async_create_ha_root_folder_if_not_exists() + except GoogleDriveApiError as err: + raise ConfigEntryNotReady from err + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleDriveConfigEntry +) -> bool: + """Unload a config entry.""" + hass.loop.call_soon(_notify_backup_listeners, hass) + return True + + +def _notify_backup_listeners(hass: HomeAssistant) -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py new file mode 100644 index 00000000000..a26512db35b --- /dev/null +++ b/homeassistant/components/google_drive/api.py @@ -0,0 +1,201 @@ +"""API for Google Drive bound to Home Assistant OAuth.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +import json +import logging +from typing import Any + +from aiohttp import ClientSession, ClientTimeout, StreamReader +from aiohttp.client_exceptions import ClientError, ClientResponseError +from google_drive_api.api import AbstractAuth, GoogleDriveApi + +from homeassistant.components.backup import AgentBackup +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) +from homeassistant.helpers import config_entry_oauth2_flow + +_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600 + +_LOGGER = logging.getLogger(__name__) + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Google Drive authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize AsyncConfigEntryAuth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + try: + await self._oauth_session.async_ensure_token_valid() + except ClientError as ex: + if ( + self._oauth_session.config_entry.state + is ConfigEntryState.SETUP_IN_PROGRESS + ): + if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from ex + raise ConfigEntryNotReady from ex + if hasattr(ex, "status") and ex.status == 400: + self._oauth_session.config_entry.async_start_reauth( + self._oauth_session.hass + ) + raise HomeAssistantError(ex) from ex + return str(self._oauth_session.token[CONF_ACCESS_TOKEN]) + + +class AsyncConfigFlowAuth(AbstractAuth): + """Provide authentication tied to a fixed token for the config flow.""" + + def __init__( + self, + websession: ClientSession, + token: str, + ) -> None: + """Initialize AsyncConfigFlowAuth.""" + super().__init__(websession) + self._token = token + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + return self._token + + +class DriveClient: + """Google Drive client.""" + + def __init__( + self, + ha_instance_id: str, + auth: AbstractAuth, + ) -> None: + """Initialize Google Drive client.""" + self._ha_instance_id = ha_instance_id + self._api = GoogleDriveApi(auth) + + async def async_get_email_address(self) -> str: + """Get email address of the current user.""" + res = await self._api.get_user(params={"fields": "user(emailAddress)"}) + return str(res["user"]["emailAddress"]) + + async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]: + """Create Home Assistant folder if it doesn't exist.""" + fields = "id,name" + query = " and ".join( + [ + "properties has { key='home_assistant' and value='root' }", + f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + "trashed=false", + ] + ) + res = await self._api.list_files( + params={"q": query, "fields": f"files({fields})"} + ) + for file in res["files"]: + _LOGGER.debug("Found existing folder: %s", file) + return str(file["id"]), str(file["name"]) + + file_metadata = { + "name": "Home Assistant", + "mimeType": "application/vnd.google-apps.folder", + "properties": { + "home_assistant": "root", + "instance_id": self._ha_instance_id, + }, + } + _LOGGER.debug("Creating new folder with metadata: %s", file_metadata) + res = await self._api.create_file(params={"fields": fields}, json=file_metadata) + _LOGGER.debug("Created folder: %s", res) + return str(res["id"]), str(res["name"]) + + async def async_upload_backup( + self, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + ) -> None: + """Upload a backup.""" + folder_id, _ = await self.async_create_ha_root_folder_if_not_exists() + backup_metadata = { + "name": f"{backup.name} {backup.date}.tar", + "description": json.dumps(backup.as_dict()), + "parents": [folder_id], + "properties": { + "home_assistant": "backup", + "instance_id": self._ha_instance_id, + "backup_id": backup.backup_id, + }, + } + _LOGGER.debug( + "Uploading backup: %s with Google Drive metadata: %s", + backup.backup_id, + backup_metadata, + ) + await self._api.upload_file( + backup_metadata, + open_stream, + timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT), + ) + _LOGGER.debug( + "Uploaded backup: %s to: '%s'", + backup.backup_id, + backup_metadata["name"], + ) + + async def async_list_backups(self) -> list[AgentBackup]: + """List backups.""" + query = " and ".join( + [ + "properties has { key='home_assistant' and value='backup' }", + f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + "trashed=false", + ] + ) + res = await self._api.list_files( + params={"q": query, "fields": "files(description)"} + ) + backups = [] + for file in res["files"]: + backup = AgentBackup.from_dict(json.loads(file["description"])) + backups.append(backup) + return backups + + async def async_get_backup_file_id(self, backup_id: str) -> str | None: + """Get file_id of backup if it exists.""" + query = " and ".join( + [ + "properties has { key='home_assistant' and value='backup' }", + f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + f"properties has {{ key='backup_id' and value='{backup_id}' }}", + ] + ) + res = await self._api.list_files(params={"q": query, "fields": "files(id)"}) + for file in res["files"]: + return str(file["id"]) + return None + + async def async_delete(self, file_id: str) -> None: + """Delete file.""" + await self._api.delete_file(file_id) + + async def async_download(self, file_id: str) -> StreamReader: + """Download a file.""" + resp = await self._api.get_file_content( + file_id, timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT) + ) + return resp.content diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py new file mode 100644 index 00000000000..c2f59b298cb --- /dev/null +++ b/homeassistant/components/google_drive/application_credentials.py @@ -0,0 +1,21 @@ +"""application_credentials platform for Google Drive.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token", + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py new file mode 100644 index 00000000000..73e5902f8f5 --- /dev/null +++ b/homeassistant/components/google_drive/backup.py @@ -0,0 +1,142 @@ +"""Backup platform for the Google Drive integration.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +import logging +from typing import Any + +from google_drive_api.exceptions import GoogleDriveApiError + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator +from homeassistant.util import slugify + +from . import DATA_BACKUP_AGENT_LISTENERS, GoogleDriveConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries = hass.config_entries.async_loaded_entries(DOMAIN) + return [GoogleDriveBackupAgent(entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +class GoogleDriveBackupAgent(BackupAgent): + """Google Drive backup agent.""" + + domain = DOMAIN + + def __init__(self, config_entry: GoogleDriveConfigEntry) -> None: + """Initialize the cloud backup sync agent.""" + super().__init__() + assert config_entry.unique_id + self.name = config_entry.title + self.unique_id = slugify(config_entry.unique_id) + self._client = config_entry.runtime_data + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + try: + await self._client.async_upload_backup(open_stream, backup) + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + raise BackupAgentError(f"Failed to upload backup: {err}") from err + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + try: + return await self._client.async_list_backups() + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + raise BackupAgentError(f"Failed to list backups: {err}") from err + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + backups = await self.async_list_backups() + for backup in backups: + if backup.backup_id == backup_id: + return backup + return None + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + _LOGGER.debug("Downloading backup_id: %s", backup_id) + try: + file_id = await self._client.async_get_backup_file_id(backup_id) + if file_id: + _LOGGER.debug("Downloading file_id: %s", file_id) + stream = await self._client.async_download(file_id) + return ChunkAsyncStreamIterator(stream) + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + raise BackupAgentError(f"Failed to download backup: {err}") from err + raise BackupAgentError("Backup not found") + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + _LOGGER.debug("Deleting backup_id: %s", backup_id) + try: + file_id = await self._client.async_get_backup_file_id(backup_id) + if file_id: + _LOGGER.debug("Deleting file_id: %s", file_id) + await self._client.async_delete(file_id) + _LOGGER.debug("Deleted backup_id: %s", backup_id) + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + raise BackupAgentError(f"Failed to delete backup: {err}") from err diff --git a/homeassistant/components/google_drive/config_flow.py b/homeassistant/components/google_drive/config_flow.py new file mode 100644 index 00000000000..fb74af42210 --- /dev/null +++ b/homeassistant/components/google_drive/config_flow.py @@ -0,0 +1,114 @@ +"""Config flow for the Google Drive integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any, cast + +from google_drive_api.exceptions import GoogleDriveApiError + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow, instance_id +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import AsyncConfigFlowAuth, DriveClient +from .const import DOMAIN + +DEFAULT_NAME = "Google Drive" +DRIVE_FOLDER_URL_PREFIX = "https://drive.google.com/drive/folders/" +OAUTH2_SCOPES = [ + "https://www.googleapis.com/auth/drive.file", +] + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Drive OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @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.""" + return { + "scope": " ".join(OAUTH2_SCOPES), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + 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[str, Any]) -> ConfigFlowResult: + """Create an entry for the flow, or update existing entry.""" + client = DriveClient( + await instance_id.async_get(self.hass), + AsyncConfigFlowAuth( + async_get_clientsession(self.hass), data[CONF_TOKEN][CONF_ACCESS_TOKEN] + ), + ) + + try: + email_address = await client.async_get_email_address() + except GoogleDriveApiError as err: + self.logger.error("Error getting email address: %s", err) + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": str(err)}, + ) + except Exception: + self.logger.exception("Unknown error occurred") + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(email_address) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + self._abort_if_unique_id_mismatch( + reason="wrong_account", + description_placeholders={"email": cast(str, reauth_entry.unique_id)}, + ) + return self.async_update_reload_and_abort(reauth_entry, data=data) + + self._abort_if_unique_id_configured() + + try: + ( + folder_id, + folder_name, + ) = await client.async_create_ha_root_folder_if_not_exists() + except GoogleDriveApiError as err: + self.logger.error("Error creating folder: %s", str(err)) + return self.async_abort( + reason="create_folder_failure", + description_placeholders={"message": str(err)}, + ) + + return self.async_create_entry( + title=DEFAULT_NAME, + data=data, + description_placeholders={ + "folder_name": folder_name, + "url": f"{DRIVE_FOLDER_URL_PREFIX}{folder_id}", + }, + ) diff --git a/homeassistant/components/google_drive/const.py b/homeassistant/components/google_drive/const.py new file mode 100644 index 00000000000..3f0b3e9d610 --- /dev/null +++ b/homeassistant/components/google_drive/const.py @@ -0,0 +1,5 @@ +"""Constants for the Google Drive integration.""" + +from __future__ import annotations + +DOMAIN = "google_drive" diff --git a/homeassistant/components/google_drive/manifest.json b/homeassistant/components/google_drive/manifest.json new file mode 100644 index 00000000000..a1abb9b260a --- /dev/null +++ b/homeassistant/components/google_drive/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "google_drive", + "name": "Google Drive", + "after_dependencies": ["backup"], + "codeowners": ["@tronikos"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/google_drive", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["google_drive_api"], + "quality_scale": "platinum", + "requirements": ["python-google-drive-api==0.0.2"] +} diff --git a/homeassistant/components/google_drive/quality_scale.yaml b/homeassistant/components/google_drive/quality_scale.yaml new file mode 100644 index 00000000000..70627a6a6d7 --- /dev/null +++ b/homeassistant/components/google_drive/quality_scale.yaml @@ -0,0 +1,113 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions. + appropriate-polling: + status: exempt + comment: No polling. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No entities. + entity-unique-id: + status: exempt + comment: No entities. + has-entity-name: + status: exempt + comment: No entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration options. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: No entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: No entities. + parallel-updates: + status: exempt + comment: No actions and no entities. + reauthentication-flow: done + test-coverage: done + + # Gold + devices: + status: exempt + comment: No devices. + diagnostics: + status: exempt + comment: No data to diagnose. + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: + status: exempt + comment: No updates. + docs-examples: + status: exempt + comment: | + This integration only serves backup. + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: No devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: No devices. + entity-category: + status: exempt + comment: No entities. + entity-device-class: + status: exempt + comment: No entities. + entity-disabled-by-default: + status: exempt + comment: No entities. + entity-translations: + status: exempt + comment: No entities. + exception-translations: done + icon-translations: + status: exempt + comment: No entities. + reconfiguration-flow: + status: exempt + comment: No configuration options. + repair-issues: + status: exempt + comment: No repairs. + stale-devices: + status: exempt + comment: No devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json new file mode 100644 index 00000000000..3441bec4294 --- /dev/null +++ b/homeassistant/components/google_drive/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Drive integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" + } + }, + "abort": { + "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%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "access_not_configured": "Unable to access the Google Drive API:\n\n{message}", + "create_folder_failure": "Error while creating Google Drive folder:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with {email}." + }, + "create_entry": { + "default": "Using [{folder_name}]({url}) folder. Feel free to rename it in Google Drive as you wish." + } + }, + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + } +} diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 81cc7ab8a73..db2df9cddd3 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -11,18 +11,15 @@ import google.generativeai as genai from google.generativeai import protos import google.generativeai.types as genai_types from google.protobuf.json_format import MessageToDict -import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation -from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import device_registry as dr, intent, llm, template +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid as ulid_util from .const import ( CONF_CHAT_MODEL, @@ -152,6 +149,17 @@ def _escape_decode(value: Any) -> Any: return value +def _chat_message_convert( + message: conversation.Content | conversation.NativeContent[genai_types.ContentDict], +) -> genai_types.ContentDict: + """Convert any native chat message for this agent to the native format.""" + if message.role == "native": + return message.content + + role = "model" if message.role == "assistant" else message.role + return {"role": role, "parts": message.content} + + class GoogleGenerativeAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -163,7 +171,6 @@ class GoogleGenerativeAIConversationEntity( def __init__(self, entry: ConfigEntry) -> None: """Initialize the agent.""" self.entry = entry - self.history: dict[str, list[genai_types.ContentType]] = {} self._attr_unique_id = entry.entry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, @@ -202,49 +209,37 @@ class GoogleGenerativeAIConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" - result = conversation.ConversationResult( - response=intent.IntentResponse(language=user_input.language), - conversation_id=user_input.conversation_id or ulid_util.ulid_now(), - ) - assert result.conversation_id + async with conversation.async_get_chat_session( + self.hass, user_input + ) as session: + return await self._async_handle_message(user_input, session) - llm_context = llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ) - llm_api: llm.APIInstance | None = None - tools: list[dict[str, Any]] | None = None - if self.entry.options.get(CONF_LLM_HASS_API): - try: - llm_api = await llm.async_get_api( - self.hass, - self.entry.options[CONF_LLM_HASS_API], - llm_context, - ) - except HomeAssistantError as err: - LOGGER.error("Error getting LLM API: %s", err) - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Error preparing LLM API: {err}", - ) - return result - tools = [ - _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools - ] + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + session: conversation.ChatSession[genai_types.ContentDict], + ) -> conversation.ConversationResult: + """Call the API.""" + + assert user_input.agent_id + options = self.entry.options try: - prompt = await self._async_render_prompt(user_input, llm_api, llm_context) - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem with my template: {err}", + await session.async_update_llm_data( + DOMAIN, + user_input, + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), ) - return result + except conversation.ConverseError as err: + return err.as_conversation_result() + + tools: list[dict[str, Any]] | None = None + if session.llm_api: + tools = [ + _format_tool(tool, session.llm_api.custom_serializer) + for tool in session.llm_api.tools + ] model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) # Gemini 1.0 doesn't support system_instruction while 1.5 does. @@ -254,6 +249,9 @@ class GoogleGenerativeAIConversationEntity( "gemini-1.0" not in model_name and "gemini-pro" not in model_name ) + prompt, *messages = [ + _chat_message_convert(message) for message in session.async_get_messages() + ] model = genai.GenerativeModel( model_name=model_name, generation_config={ @@ -281,27 +279,15 @@ class GoogleGenerativeAIConversationEntity( ), }, tools=tools or None, - system_instruction=prompt if supports_system_instruction else None, + system_instruction=prompt["parts"] if supports_system_instruction else None, ) - messages = self.history.get(result.conversation_id, []) if not supports_system_instruction: - if not messages: - messages = [{}, {"role": "model", "parts": "Ok"}] - messages[0] = {"role": "user", "parts": prompt} - - LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages) - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, - { - # Make a copy to attach it to the trace event. - "messages": messages[:] - if supports_system_instruction - else messages[2:], - "prompt": prompt, - "tools": [*llm_api.tools] if llm_api else None, - }, - ) + messages = [ + {"role": "user", "parts": prompt["parts"]}, + {"role": "model", "parts": "Ok"}, + *messages, + ] chat = model.start_chat(history=messages) chat_request = user_input.text @@ -326,24 +312,30 @@ class GoogleGenerativeAIConversationEntity( f"Sorry, I had a problem talking to Google Generative AI: {err}" ) - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - error, - ) - return result + raise HomeAssistantError(error) from err LOGGER.debug("Response: %s", chat_response.parts) if not chat_response.parts: - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem getting a response from Google Generative AI.", + raise HomeAssistantError( + "Sorry, I had a problem getting a response from Google Generative AI." ) - return result - self.history[result.conversation_id] = chat.history + content = " ".join( + [part.text.strip() for part in chat_response.parts if part.text] + ) + if content: + session.async_add_message( + conversation.Content( + role="assistant", + agent_id=user_input.agent_id, + content=content, + ) + ) + function_calls = [ part.function_call for part in chat_response.parts if part.function_call ] - if not function_calls or not llm_api: + + if not function_calls or not session.llm_api: break tool_responses = [] @@ -351,16 +343,8 @@ class GoogleGenerativeAIConversationEntity( tool_call = MessageToDict(function_call._pb) # noqa: SLF001 tool_name = tool_call["name"] tool_args = _escape_decode(tool_call["args"]) - LOGGER.debug("Tool call: %s(%s)", tool_name, tool_args) tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args) - try: - function_response = await llm_api.async_call_tool(tool_input) - except (HomeAssistantError, vol.Invalid) as e: - function_response = {"error": type(e).__name__} - if str(e): - function_response["error_text"] = str(e) - - LOGGER.debug("Tool response: %s", function_response) + function_response = await session.async_call_tool(tool_input) tool_responses.append( protos.Part( function_response=protos.FunctionResponse( @@ -369,47 +353,20 @@ class GoogleGenerativeAIConversationEntity( ) ) chat_request = protos.Content(parts=tool_responses) + session.async_add_message( + conversation.NativeContent( + agent_id=user_input.agent_id, + content=chat_request, + ) + ) - result.response.async_set_speech( + response = intent.IntentResponse(language=user_input.language) + response.async_set_speech( " ".join([part.text.strip() for part in chat_response.parts if part.text]) ) - return result - - async def _async_render_prompt( - self, - user_input: conversation.ConversationInput, - llm_api: llm.APIInstance | None, - llm_context: llm.LLMContext, - ) -> str: - user_name: str | None = None - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - ): - user_name = user.name - - parts = [ - template.Template( - llm.BASE_PROMPT - + self.entry.options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) - ] - - if llm_api: - parts.append(llm_api.api_prompt) - - return "\n".join(parts) + return conversation.ConversationResult( + response=response, conversation_id=session.conversation_id + ) async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 31eca8fba01..fd50295a6a1 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py index f289fae2456..ace56bf9354 100644 --- a/homeassistant/components/google_pubsub/__init__.py +++ b/homeassistant/components/google_pubsub/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import Event, EventStateChangedData, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 3f34b23d522..942db675b5a 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -20,11 +20,11 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ConfigEntrySelector from .const import DEFAULT_ACCESS, DOMAIN diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 08de293bc7d..a29d3d75b3e 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index a764036321b..a3f9c236136 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -25,7 +25,7 @@ from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.location import find_coordinates -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ATTRIBUTION, diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 3dd421d99da..6ce1c49410f 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle, dt as dt_util diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 50a98e277a6..7c7612ed201 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -10,8 +10,7 @@ from homeassistant.components.device_tracker import ATTR_BATTERY from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( diff --git a/homeassistant/components/graphite/__init__.py b/homeassistant/components/graphite/__init__.py index 336ca6ba2cb..8d1864f5522 100644 --- a/homeassistant/components/graphite/__init__.py +++ b/homeassistant/components/graphite/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py index 083d431e338..e3acbcd56e9 100644 --- a/homeassistant/components/greeneye_monitor/__init__.py +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/greenwave/light.py b/homeassistant/components/greenwave/light.py index 89d3ca3a535..9b7a3cf29ea 100644 --- a/homeassistant/components/greenwave/light.py +++ b/homeassistant/components/greenwave/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index fdef327cb73..d6a9a6fd3c7 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -28,9 +28,8 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index fd9de62c016..bb78aff8faf 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -20,7 +20,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index f9e9c31ce46..2637a55f772 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -19,11 +19,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_OFFSET, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py index dfbcfd4c4ac..7224c0f8f7e 100644 --- a/homeassistant/components/hardware/websocket_api.py +++ b/homeassistant/components/hardware/websocket_api.py @@ -14,7 +14,7 @@ from homeassistant.components import websocket_api from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .hardware import async_process_hardware_platforms diff --git a/homeassistant/components/harman_kardon_avr/media_player.py b/homeassistant/components/harman_kardon_avr/media_player.py index b8d9f27bcf1..22bc1a6d529 100644 --- a/homeassistant/components/harman_kardon_avr/media_player.py +++ b/homeassistant/components/harman_kardon_avr/media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index efbd4b2ac02..43bf0a348c0 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -20,8 +20,7 @@ from homeassistant.components.remote import ( RemoteEntityFeature, ) from homeassistant.core import HassJob, HomeAssistant, callback -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +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 homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 6ca89ee24be..8589bc0f134 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -14,7 +14,7 @@ from homeassistant.auth.providers import homeassistant as auth_ha from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index d49fafb886f..b9439183d8c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -5,8 +5,10 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import logging +import os from pathlib import Path from typing import Any, cast +from uuid import UUID from aiohasupervisor import SupervisorClient from aiohasupervisor.exceptions import ( @@ -29,9 +31,11 @@ from homeassistant.components.backup import ( BackupReaderWriterError, CreateBackupEvent, Folder, + IdleEvent, IncorrectPasswordError, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, ) @@ -44,7 +48,9 @@ from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client LOCATION_CLOUD_BACKUP = ".cloud_backup" +LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") +RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" _LOGGER = logging.getLogger(__name__) @@ -95,7 +101,7 @@ def async_register_backup_agents_listener( def _backup_details_to_agent_backup( - details: supervisor_backups.BackupComplete, + details: supervisor_backups.BackupComplete, location: str | None ) -> AgentBackup: """Convert a supervisor backup details object to an agent backup.""" homeassistant_included = details.homeassistant is not None @@ -107,6 +113,7 @@ def _backup_details_to_agent_backup( AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) for addon in details.addons ] + location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, @@ -117,8 +124,8 @@ def _backup_details_to_agent_backup( homeassistant_included=homeassistant_included, homeassistant_version=details.homeassistant, name=details.name, - protected=details.protected, - size=details.size_bytes, + protected=details.location_attributes[location].protected, + size=details.location_attributes[location].size_bytes, ) @@ -133,7 +140,7 @@ class SupervisorBackupAgent(BackupAgent): self._hass = hass self._backup_dir = Path("/backups") self._client = get_supervisor_client(hass) - self.name = name + self.name = self.unique_id = name self.location = location async def async_download_backup( @@ -156,8 +163,23 @@ class SupervisorBackupAgent(BackupAgent): ) -> None: """Upload a backup. - Not required for supervisor, the SupervisorBackupReaderWriter stores files. + The upload will be skipped if the backup already exists in the agent's location. """ + if await self.async_get_backup(backup.backup_id): + _LOGGER.debug( + "Backup %s already exists in location %s", + backup.backup_id, + self.location, + ) + return + stream = await open_stream() + upload_options = supervisor_backups.UploadBackupOptions( + location={self.location} + ) + await self._client.backups.upload_backup( + stream, + upload_options, + ) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" @@ -167,7 +189,7 @@ class SupervisorBackupAgent(BackupAgent): if not backup.locations or self.location not in backup.locations: continue details = await self._client.backups.backup_info(backup.slug) - result.append(_backup_details_to_agent_backup(details)) + result.append(_backup_details_to_agent_backup(details, self.location)) return result async def async_get_backup( @@ -176,10 +198,13 @@ class SupervisorBackupAgent(BackupAgent): **kwargs: Any, ) -> AgentBackup | None: """Return a backup.""" - details = await self._client.backups.backup_info(backup_id) + try: + details = await self._client.backups.backup_info(backup_id) + except SupervisorNotFoundError: + return None if self.location not in details.locations: return None - return _backup_details_to_agent_backup(details) + return _backup_details_to_agent_backup(details, self.location) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Remove a backup.""" @@ -190,10 +215,6 @@ class SupervisorBackupAgent(BackupAgent): location={self.location} ), ) - except SupervisorBadRequestError as err: - if err.args[0] != "Backup does not exist": - raise - _LOGGER.debug("Backup %s does not exist", backup_id) except SupervisorNotFoundError: _LOGGER.debug("Backup %s does not exist", backup_id) @@ -244,7 +265,41 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = [agent.location for agent in hassio_agents] + + # Supervisor does not support creating backups spread across multiple + # locations, where some locations are encrypted and some are not. + # It's inefficient to let core do all the copying so we want to let + # supervisor handle as much as possible. + # Therefore, we split the locations into two lists: encrypted and decrypted. + # The longest list will be sent to supervisor, and the remaining locations + # will be handled by async_upload_backup. + # If the lists are the same length, it does not matter which one we send, + # we send the encrypted list to have a well defined behavior. + encrypted_locations: list[str | None] = [] + decrypted_locations: list[str | None] = [] + agents_settings = manager.config.data.agents + for hassio_agent in hassio_agents: + if password is not None: + if agent_settings := agents_settings.get(hassio_agent.agent_id): + if agent_settings.protected: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + else: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + _LOGGER.debug("Encrypted locations: %s", encrypted_locations) + _LOGGER.debug("Decrypted locations: %s", decrypted_locations) + if hassio_agents: + if len(encrypted_locations) >= len(decrypted_locations): + locations = encrypted_locations + else: + locations = decrypted_locations + password = None + else: + locations = [] + locations = locations or [LOCATION_CLOUD_BACKUP] try: backup = await self._client.backups.partial_backup( @@ -255,7 +310,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): name=backup_name, password=password, compressed=True, - location=locations or LOCATION_CLOUD_BACKUP, + location=locations, homeassistant_exclude_database=not include_database, background=True, extra=extra_metadata, @@ -265,7 +320,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): raise BackupReaderWriterError(f"Error creating backup: {err}") from err backup_task = self._hass.async_create_task( self._async_wait_for_backup( - backup, remove_after_upload=not bool(locations) + backup, + locations, + remove_after_upload=locations == [LOCATION_CLOUD_BACKUP], ), name="backup_manager_create_backup", eager_start=False, # To ensure the task is not started before we return @@ -274,7 +331,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): return (NewBackup(backup_job_id=backup.job_id), backup_task) async def _async_wait_for_backup( - self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool + self, + backup: supervisor_backups.NewBackup, + locations: list[str | None], + *, + remove_after_upload: bool, ) -> WrittenBackup: """Wait for a backup to complete.""" backup_complete = asyncio.Event() @@ -288,8 +349,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): backup_id = data.get("reference") backup_complete.set() + unsub = self._async_listen_job_events(backup.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(backup.job_id, on_job_progress) + await self._get_job_state(backup.job_id, on_job_progress) await backup_complete.wait() finally: unsub() @@ -325,7 +387,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) from err return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) @@ -345,20 +407,19 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = {agent.location for agent in hassio_agents} + locations = [agent.location for agent in hassio_agents] + locations = locations or [LOCATION_CLOUD_BACKUP] backup_id = await self._client.backups.upload_backup( stream, - supervisor_backups.UploadBackupOptions( - location=locations or {LOCATION_CLOUD_BACKUP} - ), + supervisor_backups.UploadBackupOptions(location=set(locations)), ) async def open_backup() -> AsyncIterator[bytes]: return await self._client.backups.download_backup(backup_id) async def remove_backup() -> None: - if locations: + if locations != [LOCATION_CLOUD_BACKUP]: return await self._client.backups.remove_backup( backup_id, @@ -370,7 +431,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): details = await self._client.backups.backup_info(backup_id) return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) @@ -445,16 +506,55 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def on_job_progress(data: Mapping[str, Any]) -> None: - """Handle backup progress.""" + """Handle backup restore progress.""" if data.get("done") is True: restore_complete.set() + unsub = self._async_listen_job_events(job.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(job.job_id, on_job_progress) + await self._get_job_state(job.job_id, on_job_progress) await restore_complete.wait() finally: unsub() + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Check restore status after core restart.""" + if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)): + _LOGGER.debug("No restore job ID found in environment") + return + + _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) + + @callback + def on_job_progress(data: Mapping[str, Any]) -> None: + """Handle backup restore progress.""" + if data.get("done") is not True: + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.IN_PROGRESS + ) + ) + return + + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.COMPLETED + ) + ) + on_progress(IdleEvent()) + unsub() + + unsub = self._async_listen_job_events(restore_job_id, on_job_progress) + try: + await self._get_job_state(restore_job_id, on_job_progress) + except SupervisorError as err: + _LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err) + unsub() + @callback def _async_listen_job_events( self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] @@ -482,6 +582,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) return unsub + async def _get_job_state( + self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + ) -> None: + """Poll a job for its state.""" + job = await self._client.jobs.get_job(UUID(job_id)) + _LOGGER.debug("Job state: %s", job) + on_event(job.to_dict()) + async def _default_agent(client: SupervisorClient) -> str: """Return the default agent for creating a backup.""" @@ -509,6 +617,7 @@ async def backup_addon_before_update( try: await backup_manager.async_create_backup( agent_ids=[await _default_agent(client)], + extra_metadata={"supervisor.addon_update": addon}, include_addons=[addon], include_all_addons=False, include_database=False, diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index c9ecf6657e8..ccc0f23fb43 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.2.2b5"], + "requirements": ["aiohasupervisor==0.2.2b6"], "single_config_entry": true } diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 17b0a5bc9ca..8e0585892f5 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from aiohasupervisor import SupervisorClient, SupervisorError +from aiohasupervisor import SupervisorError from aiohasupervisor.models import OSUpdate from awesomeversion import AwesomeVersion, AwesomeVersionStrategy @@ -297,10 +297,3 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): ) -> None: """Install an update.""" await update_core(self.hass, version, backup) - - -async def _default_agent(client: SupervisorClient) -> str: - """Return the default agent for creating a backup.""" - mounts = await client.mounts.info() - default_mount = mounts.default_backup_mount - return f"hassio.{default_mount if default_mount is not None else 'local'}" diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 23fdc721168..c046e20feab 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -12,7 +12,7 @@ from homeassistant.components.websocket_api import ActiveConnection from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py index 1aebe696e82..d9d2889848e 100644 --- a/homeassistant/components/haveibeenpwned/sensor.py +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -15,12 +15,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_EMAIL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_point_in_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 7ff00b8e282..4d9bbeb9516 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 6b4a949c0fc..3e31dd73b5d 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -33,8 +33,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback -from homeassistant.helpers import discovery, event -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery, event from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, EVENT_HDMI_CEC_UNAVAILABLE diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py index de66315a467..f44156bdcb0 100644 --- a/homeassistant/components/heatmiser/climate.py +++ b/homeassistant/components/heatmiser/climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index b119ea83064..d735469c5cb 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -6,8 +6,7 @@ from datetime import timedelta from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from . import services @@ -40,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool ): for domain, player_id in device.identifiers: if domain == DOMAIN and not isinstance(player_id, str): - device_registry.async_update_device( + device_registry.async_update_device( # type: ignore[unreachable] device.id, new_identifiers={(DOMAIN, str(player_id))} ) break diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index ee0aeb3f165..dc8989fd55b 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -8,6 +8,7 @@ entities to update. Entities subscribe to entity-specific updates within the ent from collections.abc import Callable, Sequence from datetime import datetime, timedelta import logging +from typing import Any from pyheos import ( Credentials, @@ -23,7 +24,7 @@ from pyheos import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_call_later @@ -68,6 +69,11 @@ class HeosCoordinator(DataUpdateCoordinator[None]): """Get input sources across all devices.""" return self._inputs + @property + def favorites(self) -> dict[int, MediaItem]: + """Get favorite stations.""" + return self._favorites + async def async_setup(self) -> None: """Set up the coordinator; connect to the host; and retrieve initial data.""" # Add before connect as it may occur during initial connection @@ -101,7 +107,9 @@ class HeosCoordinator(DataUpdateCoordinator[None]): await self.heos.disconnect() await super().async_shutdown() - def async_add_listener(self, update_callback, context=None) -> Callable[[], None]: + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: """Add a listener for the coordinator.""" remove_listener = super().async_add_listener(update_callback, context) # Update entities so group_member entity_ids fully populate. diff --git a/homeassistant/components/heos/diagnostics.py b/homeassistant/components/heos/diagnostics.py new file mode 100644 index 00000000000..bf33fc9bc15 --- /dev/null +++ b/homeassistant/components/heos/diagnostics.py @@ -0,0 +1,90 @@ +"""Define the HEOS integration diagnostics module.""" + +from collections.abc import Mapping, Sequence +import dataclasses +from typing import Any + +from pyheos import HeosError + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import ATTR_PASSWORD, ATTR_USERNAME, DOMAIN +from .coordinator import HeosConfigEntry + +TO_REDACT = [ + ATTR_PASSWORD, + ATTR_USERNAME, + "signed_in_username", + "serial", + "serial_number", +] + + +def _as_dict( + data: Any, redact: bool = False +) -> Mapping[str, Any] | Sequence[Any] | Any: + """Convert dataclasses to dicts within various data structures.""" + if dataclasses.is_dataclass(data): + data_dict = dataclasses.asdict(data) # type: ignore[arg-type] + return data_dict if not redact else async_redact_data(data_dict, TO_REDACT) + if not isinstance(data, (Mapping, Sequence)): + return data + if isinstance(data, Sequence): + return [_as_dict(val) for val in data] + return {k: _as_dict(v) for k, v in data.items()} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: HeosConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = config_entry.runtime_data + diagnostics = { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "heos": { + "connection_state": coordinator.heos.connection_state, + "current_credentials": _as_dict( + coordinator.heos.current_credentials, redact=True + ), + }, + "groups": _as_dict(coordinator.heos.groups), + "source_list": coordinator.async_get_source_list(), + "inputs": _as_dict(coordinator.inputs), + "favorites": _as_dict(coordinator.favorites), + } + # Try getting system information + try: + system_info = await coordinator.heos.get_system_info() + except HeosError as err: + diagnostics["system"] = {"error": str(err)} + else: + diagnostics["system"] = _as_dict(system_info, redact=True) + return diagnostics + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: HeosConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + entity_registry = er.async_get(hass) + entities = entity_registry.entities.get_entries_for_device_id(device.id, True) + player_id = next( + int(value) for domain, value in device.identifiers if domain == DOMAIN + ) + player = config_entry.runtime_data.heos.players.get(player_id) + return { + "device": async_redact_data(device.dict_repr, TO_REDACT), + "entities": [ + { + "entity": entity.as_partial_dict, + "state": state.as_dict() + if (state := hass.states.get(entity.entity_id)) + else None, + } + for entity in entities + ], + "player": _as_dict(player, redact=True), + } diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 2f0945635c5..b53cb94d8e7 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -135,7 +135,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): def __init__(self, coordinator: HeosCoordinator, player: HeosPlayer) -> None: """Initialize.""" - self._media_position_updated_at = None + self._media_position_updated_at: datetime | None = None self._player: HeosPlayer = player self._attr_unique_id = str(player.player_id) model_parts = player.model.split(maxsplit=1) @@ -151,7 +151,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): ) super().__init__(coordinator, context=player.player_id) - async def _player_update(self, event): + async def _player_update(self, event: str) -> None: """Handle player attribute updated.""" if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: self._media_position_updated_at = utcnow() diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index d48bcc492cd..f5066d0a743 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -37,7 +37,7 @@ rules: comment: 99% test coverage # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: todo comment: Explore if this is possible. @@ -64,4 +64,4 @@ rules: inject-websession: status: done comment: The integration does not use websession - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index f4d5961cc47..dc11bb7a76d 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, issue_registry as ir from .const import ( @@ -28,7 +28,7 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema( HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) -def register(hass: HomeAssistant): +def register(hass: HomeAssistant) -> None: """Register HEOS services.""" hass.services.async_register( DOMAIN, @@ -46,7 +46,6 @@ def register(hass: HomeAssistant): def _get_controller(hass: HomeAssistant) -> Heos: """Get the HEOS controller instance.""" - _LOGGER.warning( "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release" ) @@ -79,16 +78,25 @@ async def _sign_in_handler(service: ServiceCall) -> None: try: await controller.sign_in(username, password) except CommandAuthenticationError as err: - _LOGGER.error("Sign in failed: %s", err) + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="sign_in_auth_error" + ) from err except HeosError as err: - _LOGGER.error("Unable to sign in: %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sign_in_error", + translation_placeholders={"error": str(err)}, + ) from err async def _sign_out_handler(service: ServiceCall) -> None: """Sign out of the HEOS account.""" - controller = _get_controller(service.hass) try: await controller.sign_out() except HeosError as err: - _LOGGER.error("Unable to sign out: %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sign_out_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 907804d10e1..4092d4360db 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -100,6 +100,15 @@ "integration_not_loaded": { "message": "The HEOS integration is not loaded" }, + "sign_in_auth_error": { + "message": "Failed to sign in: Invalid username and/or password" + }, + "sign_in_error": { + "message": "Unable to sign in: {error}" + }, + "sign_out_error": { + "message": "Unable to sign out: {error}" + }, "not_heos_media_player": { "message": "Entity {entity_id} is not a HEOS media player entity" }, diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index c2b70de148c..6425b5ffbed 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -31,7 +31,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( EntitySelector, LocationSelector, diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index 6591f4cb5cc..65e1305e44e 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -27,7 +27,7 @@ import voluptuous as vol from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 0656733db6b..76cca5079e4 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hikvisioncam/switch.py b/homeassistant/components/hikvisioncam/switch.py index 653d5a07174..aa16097f402 100644 --- a/homeassistant/components/hikvisioncam/switch.py +++ b/homeassistant/components/hikvisioncam/switch.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hisense_aehw4a1/__init__.py b/homeassistant/components/hisense_aehw4a1/__init__.py index d20f6d13217..3694853fb5a 100644 --- a/homeassistant/components/hisense_aehw4a1/__init__.py +++ b/homeassistant/components/hisense_aehw4a1/__init__.py @@ -12,7 +12,7 @@ from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index ba4614bbc35..fd82b74b048 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -15,10 +15,10 @@ from homeassistant.components.recorder import get_instance, history from homeassistant.components.recorder.util import session_scope from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant, valid_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import websocket_api from .const import DOMAIN diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index e6c91453213..c57e766eaed 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -35,8 +35,8 @@ from homeassistant.helpers.event import ( async_track_state_change_event, ) from homeassistant.helpers.json import json_bytes +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES from .helpers import entities_may_have_state_changes_after, has_states_before diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 83528b73f6f..a69abe26f6c 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -10,7 +10,7 @@ import math from homeassistant.components.recorder import get_instance, history from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State from homeassistant.helpers.template import Template -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .helpers import async_calculate_period, floored_timestamp diff --git a/homeassistant/components/history_stats/helpers.py b/homeassistant/components/history_stats/helpers.py index 33a45d10735..99214a51369 100644 --- a/homeassistant/components/history_stats/helpers.py +++ b/homeassistant/components/history_stats/helpers.py @@ -9,7 +9,7 @@ import math from homeassistant.core import callback from homeassistant.exceptions import TemplateError from homeassistant.helpers.template import Template -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index e1241034aeb..b25daf56598 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index 2126f5834ce..25de2d8956e 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -16,7 +16,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TYPE, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 8d09c902f36..e941087c6fb 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import refresh_system from .const import ATTR_MODE, DOMAIN diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index ce37be96dcd..ebd92908b93 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index d7c042c2a91..a019ae0f250 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,17 +2,16 @@ from __future__ import annotations -from datetime import timedelta import logging -import re from typing import Any, cast -from requests import HTTPError +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import CommandKey, Option, OptionKey +from aiohomeconnect.model.error import HomeConnectError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_entry_oauth2_flow, @@ -21,16 +20,13 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -from . import api +from .api import AsyncConfigEntryAuth from .const import ( ATTR_KEY, ATTR_PROGRAM, ATTR_UNIT, ATTR_VALUE, - BSH_PAUSE, - BSH_RESUME, DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP, SERVICE_OPTION_ACTIVE, @@ -44,15 +40,11 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) - -type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth] +from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) -RE_CAMEL_CASE = re.compile(r"(? api.HomeConnectAppliance: - """Return a Home Connect appliance instance given a device id or a device entry.""" - if device_id is not None and device_entry is None: - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) - assert device_entry, "Either a device id or a device entry must be provided" +async def _get_client_and_ha_id( + hass: HomeAssistant, device_id: str +) -> tuple[HomeConnectClient, str]: + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ServiceValidationError("Device entry not found for device id") + entry: HomeConnectConfigEntry | None = None + for entry_id in device_entry.config_entries: + _entry = hass.config_entries.async_get_entry(entry_id) + assert _entry + if _entry.domain == DOMAIN: + entry = cast(HomeConnectConfigEntry, _entry) + break + if entry is None: + raise ServiceValidationError( + "Home Connect config entry not found for that device id" + ) ha_id = next( ( @@ -119,158 +118,148 @@ def _get_appliance( ), None, ) - assert ha_id - - def find_appliance( - entry: HomeConnectConfigEntry, - ) -> api.HomeConnectAppliance | None: - for device in entry.runtime_data.devices: - appliance = device.appliance - if appliance.haId == ha_id: - return appliance - return None - - if entry is None: - for entry_id in device_entry.config_entries: - entry = hass.config_entries.async_get_entry(entry_id) - assert entry - if entry.domain == DOMAIN: - entry = cast(HomeConnectConfigEntry, entry) - if (appliance := find_appliance(entry)) is not None: - return appliance - elif (appliance := find_appliance(entry)) is not None: - return appliance - raise ValueError(f"Appliance for device id {device_entry.id} not found") - - -def _get_appliance_or_raise_service_validation_error( - hass: HomeAssistant, device_id: str -) -> api.HomeConnectAppliance: - """Return a Home Connect appliance instance or raise a service validation error.""" - try: - return _get_appliance(hass, device_id) - except (ValueError, AssertionError) as err: + if ha_id is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="appliance_not_found", translation_placeholders={ "device_id": device_id, }, - ) from err - - -async def _run_appliance_service[*_Ts]( - hass: HomeAssistant, - appliance: api.HomeConnectAppliance, - method: str, - *args: *_Ts, - error_translation_key: str, - error_translation_placeholders: dict[str, str], -) -> None: - try: - await hass.async_add_executor_job(getattr(appliance, method), *args) - except api.HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=error_translation_key, - translation_placeholders={ - **get_dict_from_home_connect_error(err), - **error_translation_placeholders, - }, - ) from err + ) + return entry.runtime_data.client, ha_id async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - async def _async_service_program(call, method): + async def _async_service_program(call: ServiceCall, start: bool): """Execute calls to services taking a program.""" program = call.data[ATTR_PROGRAM] - device_id = call.data[ATTR_DEVICE_ID] - - options = [] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) option_key = call.data.get(ATTR_KEY) - if option_key is not None: - option = {ATTR_KEY: option_key, ATTR_VALUE: call.data[ATTR_VALUE]} - - option_unit = call.data.get(ATTR_UNIT) - if option_unit is not None: - option[ATTR_UNIT] = option_unit - - options.append(option) - await _run_appliance_service( - hass, - _get_appliance_or_raise_service_validation_error(hass, device_id), - method, - program, - options, - error_translation_key=method, - error_translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, - }, + options = ( + [ + Option( + OptionKey(option_key), + call.data[ATTR_VALUE], + unit=call.data.get(ATTR_UNIT), + ) + ] + if option_key is not None + else None ) - async def _async_service_command(call, command): - """Execute calls to services executing a command.""" - device_id = call.data[ATTR_DEVICE_ID] + try: + if start: + await client.start_program(ha_id, program_key=program, options=options) + else: + await client.set_selected_program( + ha_id, program_key=program, options=options + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_program" if start else "select_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, + }, + ) from err - appliance = _get_appliance_or_raise_service_validation_error(hass, device_id) - await _run_appliance_service( - hass, - appliance, - "execute_command", - command, - error_translation_key="execute_command", - error_translation_placeholders={"command": command}, - ) - - async def _async_service_key_value(call, method): - """Execute calls to services taking a key and value.""" - key = call.data[ATTR_KEY] + async def _async_service_set_program_options(call: ServiceCall, active: bool): + """Execute calls to services taking a program.""" + option_key = call.data[ATTR_KEY] value = call.data[ATTR_VALUE] unit = call.data.get(ATTR_UNIT) - device_id = call.data[ATTR_DEVICE_ID] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - await _run_appliance_service( - hass, - _get_appliance_or_raise_service_validation_error(hass, device_id), - method, - *((key, value) if unit is None else (key, value, unit)), - error_translation_key=method, - error_translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_KEY: key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), - }, - ) + try: + if active: + await client.set_active_program_option( + ha_id, + option_key=OptionKey(option_key), + value=value, + unit=unit, + ) + else: + await client.set_selected_program_option( + ha_id, + option_key=OptionKey(option_key), + value=value, + unit=unit, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_options_active_program" + if active + else "set_options_selected_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_KEY: option_key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) from err - async def async_service_option_active(call): + async def _async_service_command(call: ServiceCall, command_key: CommandKey): + """Execute calls to services executing a command.""" + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) + + try: + await client.put_command(ha_id, command_key=command_key, value=True) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "command": command_key.value, + }, + ) from err + + async def async_service_option_active(call: ServiceCall): """Service for setting an option for an active program.""" - await _async_service_key_value(call, "set_options_active_program") + await _async_service_set_program_options(call, True) - async def async_service_option_selected(call): + async def async_service_option_selected(call: ServiceCall): """Service for setting an option for a selected program.""" - await _async_service_key_value(call, "set_options_selected_program") + await _async_service_set_program_options(call, False) - async def async_service_setting(call): + async def async_service_setting(call: ServiceCall): """Service for changing a setting.""" - await _async_service_key_value(call, "set_setting") + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - async def async_service_pause_program(call): + try: + await client.set_setting(ha_id, setting_key=key, value=value) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_setting", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_KEY: key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) from err + + async def async_service_pause_program(call: ServiceCall): """Service for pausing a program.""" - await _async_service_command(call, BSH_PAUSE) + await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - async def async_service_resume_program(call): + async def async_service_resume_program(call: ServiceCall): """Service for resuming a paused program.""" - await _async_service_command(call, BSH_RESUME) + await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - async def async_service_select_program(call): + async def async_service_select_program(call: ServiceCall): """Service for selecting a program.""" - await _async_service_program(call, "select_program") + await _async_service_program(call, False) - async def async_service_start_program(call): + async def async_service_start_program(call: ServiceCall): """Service for starting a program.""" - await _async_service_program(call, "start_program") + await _async_service_program(call, True) hass.services.async_register( DOMAIN, @@ -323,12 +312,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) ) ) - entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - await update_all_devices(hass, entry) + config_entry_auth = AsyncConfigEntryAuth(hass, session) + + home_connect_client = HomeConnectClient(config_entry_auth) + + coordinator = HomeConnectCoordinator(hass, entry, home_connect_client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.runtime_data.start_event_listener() + return True @@ -339,21 +337,6 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -@Throttle(SCAN_INTERVAL) -async def update_all_devices( - hass: HomeAssistant, entry: HomeConnectConfigEntry -) -> None: - """Update all the devices.""" - hc_api = entry.runtime_data - - try: - await hass.async_add_executor_job(hc_api.get_devices) - for device in hc_api.devices: - await hass.async_add_executor_job(device.initialize) - except HTTPError as err: - _LOGGER.warning("Cannot update devices: %s", err.response.status_code) - - async def async_migrate_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: @@ -382,25 +365,3 @@ async def async_migrate_entry( _LOGGER.debug("Migration to version %s successful", entry.version) return True - - -def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]: - """Return a dict from a Home Connect error.""" - return { - "description": cast(dict[str, Any], err.args[0]).get("description", "?") - if len(err.args) > 0 and isinstance(err.args[0], dict) - else err.args[0] - if len(err.args) > 0 and isinstance(err.args[0], str) - else "?", - } - - -def bsh_key_to_translation_key(bsh_key: str) -> str: - """Convert a BSH key to a translation key format. - - This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, - and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. - """ - return "_".join( - RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") - ).lower() diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index 453f926c402..5d711dae032 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -1,85 +1,28 @@ """API for Home Connect bound to HASS OAuth.""" -from asyncio import run_coroutine_threadsafe -import logging +from aiohomeconnect.client import AbstractAuth +from aiohomeconnect.const import API_ENDPOINT -import homeconnect -from homeconnect.api import HomeConnectAppliance, HomeConnectError - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.dispatcher import dispatcher_send - -from .const import ATTR_KEY, ATTR_VALUE, BSH_ACTIVE_PROGRAM, SIGNAL_UPDATE_ENTITIES - -_LOGGER = logging.getLogger(__name__) +from homeassistant.helpers.httpx_client import get_async_client -class ConfigEntryAuth(homeconnect.HomeConnectAPI): +class AsyncConfigEntryAuth(AbstractAuth): """Provide Home Connect authentication tied to an OAuth2 based config entry.""" def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, + oauth_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize Home Connect Auth.""" self.hass = hass - self.config_entry = config_entry - self.session = config_entry_oauth2_flow.OAuth2Session( - hass, config_entry, implementation - ) - super().__init__(self.session.token) - self.devices: list[HomeConnectDevice] = [] + super().__init__(get_async_client(hass), host=API_ENDPOINT) + self.session = oauth_session - def refresh_tokens(self) -> dict: - """Refresh and return new Home Connect tokens using Home Assistant OAuth2 session.""" - run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self.hass.loop - ).result() + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self.session.async_ensure_token_valid() - return self.session.token - - def get_devices(self) -> list[HomeConnectAppliance]: - """Get a dictionary of devices.""" - appl: list[HomeConnectAppliance] = self.get_appliances() - self.devices = [HomeConnectDevice(self.hass, app) for app in appl] - return self.devices - - -class HomeConnectDevice: - """Generic Home Connect device.""" - - def __init__(self, hass: HomeAssistant, appliance: HomeConnectAppliance) -> None: - """Initialize the device class.""" - self.hass = hass - self.appliance = appliance - - def initialize(self) -> None: - """Fetch the info needed to initialize the device.""" - try: - self.appliance.get_status() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch appliance status. Probably offline") - try: - self.appliance.get_settings() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch settings. Probably offline") - try: - program_active = self.appliance.get_programs_active() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch active programs. Probably offline") - program_active = None - if program_active and ATTR_KEY in program_active: - self.appliance.status[BSH_ACTIVE_PROGRAM] = { - ATTR_VALUE: program_active[ATTR_KEY] - } - self.appliance.listen_events(callback=self.event_callback) - - def event_callback(self, appliance: HomeConnectAppliance) -> None: - """Handle event.""" - _LOGGER.debug("Update triggered on %s", appliance.name) - _LOGGER.debug(self.appliance.status) - dispatcher_send(self.hass, SIGNAL_UPDATE_ENTITIES, appliance.haId) + return self.session.token["access_token"] diff --git a/homeassistant/components/home_connect/application_credentials.py b/homeassistant/components/home_connect/application_credentials.py index 3d5a407b487..d66255e6810 100644 --- a/homeassistant/components/home_connect/application_credentials.py +++ b/homeassistant/components/home_connect/application_credentials.py @@ -1,10 +1,10 @@ """Application credentials platform for Home Connect.""" +from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + 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.""" diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index f9775918f16..90743c829e2 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -1,7 +1,9 @@ """Provides a binary sensor for Home Connect.""" from dataclasses import dataclass -import logging +from typing import cast + +from aiohomeconnect.model import StatusKey from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( @@ -19,26 +21,21 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) -from . import HomeConnectConfigEntry -from .api import HomeConnectDevice from .const import ( - ATTR_VALUE, - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, - BSH_REMOTE_CONTROL_ACTIVATION_STATE, - BSH_REMOTE_START_ALLOWANCE_STATE, DOMAIN, - REFRIGERATION_STATUS_DOOR_CHILLER, REFRIGERATION_STATUS_DOOR_CLOSED, - REFRIGERATION_STATUS_DOOR_FREEZER, REFRIGERATION_STATUS_DOOR_OPEN, - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, +) +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, ) from .entity import HomeConnectEntity -_LOGGER = logging.getLogger(__name__) REFRIGERATION_DOOR_BOOLEAN_MAP = { REFRIGERATION_STATUS_DOOR_CLOSED: False, REFRIGERATION_STATUS_DOOR_OPEN: True, @@ -54,19 +51,19 @@ class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription): BINARY_SENSORS = ( HomeConnectBinarySensorEntityDescription( - key=BSH_REMOTE_CONTROL_ACTIVATION_STATE, + key=StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE, translation_key="remote_control", ), HomeConnectBinarySensorEntityDescription( - key=BSH_REMOTE_START_ALLOWANCE_STATE, + key=StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, translation_key="remote_start", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.LocalControlActive", + key=StatusKey.BSH_COMMON_LOCAL_CONTROL_ACTIVE, translation_key="local_control", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.BatteryChargingState", + key=StatusKey.BSH_COMMON_BATTERY_CHARGING_STATE, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, boolean_map={ "BSH.Common.EnumType.BatteryChargingState.Charging": True, @@ -75,7 +72,7 @@ BINARY_SENSORS = ( translation_key="battery_charging_state", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.ChargingConnection", + key=StatusKey.BSH_COMMON_CHARGING_CONNECTION, device_class=BinarySensorDeviceClass.PLUG, boolean_map={ "BSH.Common.EnumType.ChargingConnection.Connected": True, @@ -84,31 +81,31 @@ BINARY_SENSORS = ( translation_key="charging_connection", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.DustBoxInserted", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED, translation_key="dust_box_inserted", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.Lifted", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LIFTED, translation_key="lifted", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.Lost", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST, translation_key="lost", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_CHILLER, + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="chiller_door", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_FREEZER, + key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="freezer_door", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + key=StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="refrigerator_door", @@ -123,19 +120,17 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect binary sensor.""" - def get_entities() -> list[BinarySensorEntity]: - entities: list[BinarySensorEntity] = [] - for device in entry.runtime_data.devices: - entities.extend( - HomeConnectBinarySensor(device, description) - for description in BINARY_SENSORS - if description.key in device.appliance.status - ) - if BSH_DOOR_STATE in device.appliance.status: - entities.append(HomeConnectDoorBinarySensor(device)) - return entities + entities: list[BinarySensorEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectBinarySensor(entry.runtime_data, appliance, description) + for description in BINARY_SENSORS + if description.key in appliance.status + ) + if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: + entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): @@ -143,25 +138,15 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): entity_description: HomeConnectBinarySensorEntityDescription - @property - def available(self) -> bool: - """Return true if the binary sensor is available.""" - return self._attr_is_on is not None - - async def async_update(self) -> None: - """Update the binary sensor's status.""" - if not self.device.appliance.status or not ( - status := self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) - ): - self._attr_is_on = None - return - if self.entity_description.boolean_map: - self._attr_is_on = self.entity_description.boolean_map.get(status) - elif status not in [True, False]: - self._attr_is_on = None - else: + def update_native_value(self) -> None: + """Set the native value of the binary sensor.""" + status = self.appliance.status[cast(StatusKey, self.bsh_key)].value + if isinstance(status, bool): self._attr_is_on = status - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) + elif self.entity_description.boolean_map: + self._attr_is_on = self.entity_description.boolean_map.get(status) + else: + self._attr_is_on = None class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): @@ -171,13 +156,15 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): def __init__( self, - device: HomeConnectDevice, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, ) -> None: """Initialize the entity.""" super().__init__( - device, + coordinator, + appliance, HomeConnectBinarySensorEntityDescription( - key=BSH_DOOR_STATE, + key=StatusKey.BSH_COMMON_DOOR_STATE, device_class=BinarySensorDeviceClass.DOOR, boolean_map={ BSH_DOOR_STATE_CLOSED: False, @@ -186,8 +173,8 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): }, ), ) - self._attr_unique_id = f"{device.appliance.haId}-Door" - self._attr_name = f"{device.appliance.name} Door" + self._attr_unique_id = f"{appliance.info.ha_id}-Door" + self._attr_name = f"{appliance.info.name} Door" async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -234,6 +221,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed from hass.""" + await super().async_will_remove_from_hass() async_delete_issue( self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" ) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index e20cf3b1fa0..127aa1ffe92 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -1,9 +1,9 @@ """Constants for the Home Connect integration.""" +from aiohomeconnect.model import EventKey, SettingKey, StatusKey + DOMAIN = "home_connect" -OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize" -OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token" APPLIANCES_WITH_PROGRAMS = ( "CleaningRobot", @@ -17,93 +17,35 @@ APPLIANCES_WITH_PROGRAMS = ( "WasherDryer", ) -BSH_POWER_STATE = "BSH.Common.Setting.PowerState" + BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" -BSH_SELECTED_PROGRAM = "BSH.Common.Root.SelectedProgram" -BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" -BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" -BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" -BSH_CHILD_LOCK_STATE = "BSH.Common.Setting.ChildLock" -BSH_REMAINING_PROGRAM_TIME = "BSH.Common.Option.RemainingProgramTime" -BSH_COMMON_OPTION_DURATION = "BSH.Common.Option.Duration" -BSH_COMMON_OPTION_PROGRAM_PROGRESS = "BSH.Common.Option.ProgramProgress" BSH_EVENT_PRESENT_STATE_PRESENT = "BSH.Common.EnumType.EventPresentState.Present" BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confirmed" BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off" -BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" + BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause" BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" -COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" -COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" - -COFFEE_EVENT_BEAN_CONTAINER_EMPTY = ( - "ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty" -) -COFFEE_EVENT_WATER_TANK_EMPTY = "ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty" -COFFEE_EVENT_DRIP_TRAY_FULL = "ConsumerProducts.CoffeeMaker.Event.DripTrayFull" - -DISHWASHER_EVENT_SALT_NEARLY_EMPTY = "Dishcare.Dishwasher.Event.SaltNearlyEmpty" -DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY = ( - "Dishcare.Dishwasher.Event.RinseAidNearlyEmpty" -) - -REFRIGERATION_INTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.Internal.Power" -REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS = ( - "Refrigeration.Common.Setting.Light.Internal.Brightness" -) -REFRIGERATION_EXTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.External.Power" -REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS = ( - "Refrigeration.Common.Setting.Light.External.Brightness" -) - -REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer" -REFRIGERATION_SUPERMODEREFRIGERATOR = ( - "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator" -) -REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled" - -REFRIGERATION_STATUS_DOOR_CHILLER = "Refrigeration.Common.Status.Door.ChillerCommon" -REFRIGERATION_STATUS_DOOR_FREEZER = "Refrigeration.Common.Status.Door.Freezer" -REFRIGERATION_STATUS_DOOR_REFRIGERATOR = "Refrigeration.Common.Status.Door.Refrigerator" REFRIGERATION_STATUS_DOOR_CLOSED = "Refrigeration.Common.EnumType.Door.States.Closed" REFRIGERATION_STATUS_DOOR_OPEN = "Refrigeration.Common.EnumType.Door.States.Open" -REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR = ( - "Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator" -) -REFRIGERATION_EVENT_DOOR_ALARM_FREEZER = ( - "Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer" -) -REFRIGERATION_EVENT_TEMP_ALARM_FREEZER = ( - "Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer" -) - -BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled" -BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness" -BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor" BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR = ( "BSH.Common.EnumType.AmbientLightColor.CustomColor" ) -BSH_AMBIENT_LIGHT_CUSTOM_COLOR = "BSH.Common.Setting.AmbientLightCustomColor" -BSH_DOOR_STATE = "BSH.Common.Status.DoorState" + BSH_DOOR_STATE_CLOSED = "BSH.Common.EnumType.DoorState.Closed" BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked" BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open" -BSH_PAUSE = "BSH.Common.Command.PauseProgram" -BSH_RESUME = "BSH.Common.Command.ResumeProgram" - -SIGNAL_UPDATE_ENTITIES = "home_connect.update_entities" SERVICE_OPTION_ACTIVE = "set_option_active" SERVICE_OPTION_SELECTED = "set_option_selected" @@ -113,51 +55,44 @@ SERVICE_SELECT_PROGRAM = "select_program" SERVICE_SETTING = "change_setting" SERVICE_START_PROGRAM = "start_program" -ATTR_ALLOWED_VALUES = "allowedvalues" -ATTR_AMBIENT = "ambient" -ATTR_BSH_KEY = "bsh_key" -ATTR_CONSTRAINTS = "constraints" -ATTR_DESC = "desc" -ATTR_DEVICE = "device" + ATTR_KEY = "key" ATTR_PROGRAM = "program" -ATTR_SENSOR_TYPE = "sensor_type" -ATTR_SIGN = "sign" -ATTR_STEPSIZE = "stepsize" ATTR_UNIT = "unit" ATTR_VALUE = "value" -SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" +SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id" SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program" SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" + OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { - "ChildLock": BSH_CHILD_LOCK_STATE, - "Operation State": BSH_OPERATION_STATE, - "Light": COOKING_LIGHTING, - "AmbientLight": BSH_AMBIENT_LIGHT_ENABLED, - "Power": BSH_POWER_STATE, - "Remaining Program Time": BSH_REMAINING_PROGRAM_TIME, - "Duration": BSH_COMMON_OPTION_DURATION, - "Program Progress": BSH_COMMON_OPTION_PROGRAM_PROGRESS, - "Remote Control": BSH_REMOTE_CONTROL_ACTIVATION_STATE, - "Remote Start": BSH_REMOTE_START_ALLOWANCE_STATE, - "Supermode Freezer": REFRIGERATION_SUPERMODEFREEZER, - "Supermode Refrigerator": REFRIGERATION_SUPERMODEREFRIGERATOR, - "Dispenser Enabled": REFRIGERATION_DISPENSER, - "Internal Light": REFRIGERATION_INTERNAL_LIGHT_POWER, - "External Light": REFRIGERATION_EXTERNAL_LIGHT_POWER, - "Chiller Door": REFRIGERATION_STATUS_DOOR_CHILLER, - "Freezer Door": REFRIGERATION_STATUS_DOOR_FREEZER, - "Refrigerator Door": REFRIGERATION_STATUS_DOOR_REFRIGERATOR, - "Door Alarm Freezer": REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, - "Door Alarm Refrigerator": REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, - "Temperature Alarm Freezer": REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, - "Bean Container Empty": COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - "Water Tank Empty": COFFEE_EVENT_WATER_TANK_EMPTY, - "Drip Tray Full": COFFEE_EVENT_DRIP_TRAY_FULL, + "ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK, + "Operation State": StatusKey.BSH_COMMON_OPERATION_STATE, + "Light": SettingKey.COOKING_COMMON_LIGHTING, + "AmbientLight": SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED, + "Power": SettingKey.BSH_COMMON_POWER_STATE, + "Remaining Program Time": EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME, + "Duration": EventKey.BSH_COMMON_OPTION_DURATION, + "Program Progress": EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, + "Remote Control": StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE, + "Remote Start": StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, + "Supermode Freezer": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER, + "Supermode Refrigerator": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR, + "Dispenser Enabled": SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED, + "Internal Light": SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER, + "External Light": SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER, + "Chiller Door": StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER, + "Freezer Door": StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, + "Refrigerator Door": StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR, + "Door Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + "Door Alarm Refrigerator": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, + "Temperature Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, + "Bean Container Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + "Water Tank Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, + "Drip Tray Full": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, } diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py new file mode 100644 index 00000000000..2c70d74150e --- /dev/null +++ b/homeassistant/components/home_connect/coordinator.py @@ -0,0 +1,258 @@ +"""Coordinator for Home Connect.""" + +import asyncio +from collections import defaultdict +from collections.abc import Callable +from dataclasses import dataclass, field +import logging +from typing import Any + +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + HomeAppliance, + SettingKey, + Status, + StatusKey, +) +from aiohomeconnect.model.error import ( + EventStreamInterruptedError, + HomeConnectApiError, + HomeConnectError, + HomeConnectRequestError, +) +from aiohomeconnect.model.program import EnumerateAvailableProgram +from propcache.api import cached_property + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN +from .utils import get_dict_from_home_connect_error + +_LOGGER = logging.getLogger(__name__) + +type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] + +EVENT_STREAM_RECONNECT_DELAY = 30 + + +@dataclass(frozen=True, kw_only=True) +class HomeConnectApplianceData: + """Class to hold Home Connect appliance data.""" + + events: dict[EventKey, Event] = field(default_factory=dict) + info: HomeAppliance + programs: list[EnumerateAvailableProgram] = field(default_factory=list) + settings: dict[SettingKey, GetSetting] + status: dict[StatusKey, Status] + + def update(self, other: "HomeConnectApplianceData") -> None: + """Update data with data from other instance.""" + self.events.update(other.events) + self.info.connected = other.info.connected + self.programs.clear() + self.programs.extend(other.programs) + self.settings.update(other.settings) + self.status.update(other.status) + + +class HomeConnectCoordinator( + DataUpdateCoordinator[dict[str, HomeConnectApplianceData]] +): + """Class to manage fetching Home Connect data.""" + + config_entry: HomeConnectConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HomeConnectConfigEntry, + client: HomeConnectClient, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.entry_id, + ) + self.client = client + + @cached_property + def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: + """Return a dict of all listeners registered for a given context.""" + listeners: dict[tuple[str, EventKey], list[CALLBACK_TYPE]] = defaultdict(list) + for listener, context in list(self._listeners.values()): + assert isinstance(context, tuple) + listeners[context].append(listener) + return listeners + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + remove_listener = super().async_add_listener(update_callback, context) + self.__dict__.pop("context_listeners", None) + + def remove_listener_and_invalidate_context_listeners() -> None: + remove_listener() + self.__dict__.pop("context_listeners", None) + + return remove_listener_and_invalidate_context_listeners + + @callback + def start_event_listener(self) -> None: + """Start event listener.""" + self.config_entry.async_create_background_task( + self.hass, + self._event_listener(), + f"home_connect-events_listener_task-{self.config_entry.entry_id}", + ) + + async def _event_listener(self) -> None: + """Match event with listener for event type.""" + while True: + try: + async for event_message in self.client.stream_all_events(): + match event_message.type: + case EventType.STATUS: + statuses = self.data[event_message.ha_id].status + for event in event_message.data.items: + status_key = StatusKey(event.key) + if status_key in statuses: + statuses[status_key].value = event.value + else: + statuses[status_key] = Status( + key=status_key, + raw_key=status_key.value, + value=event.value, + ) + + case EventType.NOTIFY: + settings = self.data[event_message.ha_id].settings + events = self.data[event_message.ha_id].events + for event in event_message.data.items: + if event.key in SettingKey: + setting_key = SettingKey(event.key) + if setting_key in settings: + settings[setting_key].value = event.value + else: + settings[setting_key] = GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=event.value, + ) + else: + events[event.key] = event + + case EventType.EVENT: + events = self.data[event_message.ha_id].events + for event in event_message.data.items: + events[event.key] = event + + self._call_event_listener(event_message) + + except (EventStreamInterruptedError, HomeConnectRequestError) as error: + _LOGGER.debug( + "Non-breaking error (%s) while listening for events," + " continuing in 30 seconds", + type(error).__name__, + ) + await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY) + except HomeConnectApiError as error: + _LOGGER.error("Error while listening for events: %s", error) + self.hass.config_entries.async_schedule_reload( + self.config_entry.entry_id + ) + break + # if there was a non-breaking error, we continue listening + # but we need to refresh the data to get the possible changes + # that happened while the event stream was interrupted + await self.async_refresh() + + @callback + def _call_event_listener(self, event_message: EventMessage): + """Call listener for event.""" + for event in event_message.data.items: + for listener in self.context_listeners.get( + (event_message.ha_id, event.key), [] + ): + listener() + + async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: + """Fetch data from Home Connect.""" + try: + appliances = await self.client.get_home_appliances() + except HomeConnectError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="fetch_api_error", + translation_placeholders=get_dict_from_home_connect_error(error), + ) from error + + appliances_data = self.data or {} + for appliance in appliances.homeappliances: + try: + settings = { + setting.key: setting + for setting in ( + await self.client.get_settings(appliance.ha_id) + ).settings + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching settings for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + settings = {} + try: + status = { + status.key: status + for status in (await self.client.get_status(appliance.ha_id)).status + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching status for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + status = {} + appliance_data = HomeConnectApplianceData( + info=appliance, settings=settings, status=status + ) + if appliance.ha_id in appliances_data: + appliances_data[appliance.ha_id].update(appliance_data) + appliance_data = appliances_data[appliance.ha_id] + else: + appliances_data[appliance.ha_id] = appliance_data + if ( + appliance.type in APPLIANCES_WITH_PROGRAMS + and not appliance_data.programs + ): + try: + appliance_data.programs.extend( + ( + await self.client.get_available_programs(appliance.ha_id) + ).programs + ) + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching programs for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + return appliances_data diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index e095bc503ab..fd74277a815 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -4,33 +4,25 @@ from __future__ import annotations from typing import Any -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.client import Client as HomeConnectClient from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from . import HomeConnectConfigEntry, _get_appliance -from .api import HomeConnectDevice +from .const import DOMAIN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]: - try: - programs = appliance.get_programs_available() - except HomeConnectError: - programs = None +async def _generate_appliance_diagnostics( + client: HomeConnectClient, appliance: HomeConnectApplianceData +) -> dict[str, Any]: return { - "connected": appliance.connected, - "status": appliance.status, - "programs": programs, - } - - -def _generate_entry_diagnostics( - devices: list[HomeConnectDevice], -) -> dict[str, dict[str, Any]]: - return { - device.appliance.haId: _generate_appliance_diagnostics(device.appliance) - for device in devices + **appliance.info.to_dict(), + "status": {key.value: status.value for key, status in appliance.status.items()}, + "settings": { + key.value: setting.value for key, setting in appliance.settings.items() + }, + "programs": [program.raw_key for program in appliance.programs], } @@ -38,14 +30,21 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return await hass.async_add_executor_job( - _generate_entry_diagnostics, entry.runtime_data.devices - ) + return { + appliance.info.ha_id: await _generate_appliance_diagnostics( + entry.runtime_data.client, appliance + ) + for appliance in entry.runtime_data.data.values() + } async def async_get_device_diagnostics( hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - appliance = _get_appliance(hass, device_entry=device, entry=entry) - return await hass.async_add_executor_job(_generate_appliance_diagnostics, appliance) + ha_id = next( + (identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN), + ) + return await _generate_appliance_diagnostics( + entry.runtime_data.client, entry.runtime_data.data[ha_id] + ) diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 0ae4a28b8d4..ba8500fe8b6 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,55 +1,56 @@ """Home Connect entity base class.""" +from abc import abstractmethod import logging +from aiohomeconnect.model import EventKey + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import HomeConnectDevice -from .const import DOMAIN, SIGNAL_UPDATE_ENTITIES +from .const import DOMAIN +from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator _LOGGER = logging.getLogger(__name__) -class HomeConnectEntity(Entity): +class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): """Generic Home Connect entity (base class).""" _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, device: HomeConnectDevice, desc: EntityDescription) -> None: + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: EntityDescription, + ) -> None: """Initialize the entity.""" - self.device = device + super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key))) + self.appliance = appliance self.entity_description = desc - self._attr_unique_id = f"{device.appliance.haId}-{self.bsh_key}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.appliance.haId)}, - manufacturer=device.appliance.brand, - model=device.appliance.vib, - name=device.appliance.name, + identifiers={(DOMAIN, appliance.info.ha_id)}, + manufacturer=appliance.info.brand, + model=appliance.info.vib, + name=appliance.info.name, ) + self.update_native_value() - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITIES, self._update_callback - ) - ) + @abstractmethod + def update_native_value(self) -> None: + """Set the value of the entity.""" @callback - def _update_callback(self, ha_id: str) -> None: - """Update data.""" - if ha_id == self.device.appliance.haId: - self.async_entity_update() - - @callback - def async_entity_update(self) -> None: - """Update the entity.""" - _LOGGER.debug("Entity update triggered on %s", self) - self.async_schedule_update_ha_state(True) + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_native_value() + self.async_write_ha_state() + _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state) @property def bsh_key(self) -> str: diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index e33017cd51f..9d1c4d7a55b 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -2,10 +2,10 @@ from dataclasses import dataclass import logging -from math import ceil -from typing import Any +from typing import Any, cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -18,27 +18,20 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error -from .api import HomeConnectDevice from .const import ( - ATTR_VALUE, - BSH_AMBIENT_LIGHT_BRIGHTNESS, - BSH_AMBIENT_LIGHT_COLOR, BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_ENABLED, - COOKING_LIGHTING, - COOKING_LIGHTING_BRIGHTNESS, DOMAIN, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_POWER, - REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_INTERNAL_LIGHT_POWER, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, ) +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -47,38 +40,38 @@ _LOGGER = logging.getLogger(__name__) class HomeConnectLightEntityDescription(LightEntityDescription): """Light entity description.""" - brightness_key: str | None = None - color_key: str | None = None + brightness_key: SettingKey | None = None + color_key: SettingKey | None = None enable_custom_color_value_key: str | None = None - custom_color_key: str | None = None + custom_color_key: SettingKey | None = None brightness_scale: tuple[float, float] = (0.0, 100.0) LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = ( HomeConnectLightEntityDescription( - key=REFRIGERATION_INTERNAL_LIGHT_POWER, - brightness_key=REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, + key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER, + brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_BRIGHTNESS, brightness_scale=(1.0, 100.0), translation_key="internal_light", ), HomeConnectLightEntityDescription( - key=REFRIGERATION_EXTERNAL_LIGHT_POWER, - brightness_key=REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, + key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER, + brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_BRIGHTNESS, brightness_scale=(1.0, 100.0), translation_key="external_light", ), HomeConnectLightEntityDescription( - key=COOKING_LIGHTING, - brightness_key=COOKING_LIGHTING_BRIGHTNESS, + key=SettingKey.COOKING_COMMON_LIGHTING, + brightness_key=SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS, brightness_scale=(10.0, 100.0), translation_key="cooking_lighting", ), HomeConnectLightEntityDescription( - key=BSH_AMBIENT_LIGHT_ENABLED, - brightness_key=BSH_AMBIENT_LIGHT_BRIGHTNESS, - color_key=BSH_AMBIENT_LIGHT_COLOR, + key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED, + brightness_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS, + color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, enable_custom_color_value_key=BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - custom_color_key=BSH_AMBIENT_LIGHT_CUSTOM_COLOR, + custom_color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR, brightness_scale=(10.0, 100.0), translation_key="ambient_light", ), @@ -92,16 +85,14 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect light.""" - def get_entities() -> list[LightEntity]: - """Get a list of entities.""" - return [ - HomeConnectLight(device, description) + async_add_entities( + [ + HomeConnectLight(entry.runtime_data, appliance, description) for description in LIGHTS - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) class HomeConnectLight(HomeConnectEntity, LightEntity): @@ -110,13 +101,17 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): entity_description: LightEntityDescription def __init__( - self, device: HomeConnectDevice, desc: HomeConnectLightEntityDescription + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectLightEntityDescription, ) -> None: """Initialize the entity.""" - super().__init__(device, desc) - def get_setting_key_if_setting_exists(setting_key: str | None) -> str | None: - if setting_key and setting_key in device.appliance.status: + def get_setting_key_if_setting_exists( + setting_key: SettingKey | None, + ) -> SettingKey | None: + if setting_key and setting_key in appliance.settings: return setting_key return None @@ -131,6 +126,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): ) self._brightness_scale = desc.brightness_scale + super().__init__(coordinator, appliance, desc) + match (self._brightness_key, self._custom_color_key): case (None, None): self._attr_color_mode = ColorMode.ONOFF @@ -144,10 +141,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Switch the light on, change brightness, change color.""" - _LOGGER.debug("Switching light on for: %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.bsh_key, True + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=True, ) except HomeConnectError as err: raise HomeAssistantError( @@ -158,15 +156,15 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - if self._custom_color_key: + if self._color_key and self._custom_color_key: if ( ATTR_RGB_COLOR in kwargs or ATTR_HS_COLOR in kwargs ) and self._enable_custom_color_value_key: try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._color_key, - self._enable_custom_color_value_key, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._color_key, + value=self._enable_custom_color_value_key, ) except HomeConnectError as err: raise HomeAssistantError( @@ -181,10 +179,10 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): if ATTR_RGB_COLOR in kwargs: hex_val = color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._custom_color_key, - f"#{hex_val}", + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._custom_color_key, + value=f"#{hex_val}", ) except HomeConnectError as err: raise HomeAssistantError( @@ -195,10 +193,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - elif (ATTR_BRIGHTNESS in kwargs or ATTR_HS_COLOR in kwargs) and ( - self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs + return + if (self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs) and ( + self._attr_hs_color is not None or ATTR_HS_COLOR in kwargs ): - brightness = 10 + ceil( + brightness = round( color_util.brightness_to_value( self._brightness_scale, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness), @@ -207,41 +206,36 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) - if hs_color is not None: - rgb = color_util.color_hsv_to_RGB( - hs_color[0], hs_color[1], brightness + rgb = color_util.color_hsv_to_RGB(hs_color[0], hs_color[1], brightness) + hex_val = color_util.color_rgb_to_hex(*rgb) + try: + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._custom_color_key, + value=f"#{hex_val}", ) - hex_val = color_util.color_rgb_to_hex(*rgb) - try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._custom_color_key, - f"#{hex_val}", - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_light_color", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - }, - ) from err + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_light_color", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + }, + ) from err + return - elif self._brightness_key and ATTR_BRIGHTNESS in kwargs: - _LOGGER.debug( - "Changing brightness for: %s, to: %s", - self.name, - kwargs[ATTR_BRIGHTNESS], - ) - brightness = ceil( + if self._brightness_key and ATTR_BRIGHTNESS in kwargs: + brightness = round( color_util.brightness_to_value( self._brightness_scale, kwargs[ATTR_BRIGHTNESS] ) ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self._brightness_key, brightness + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._brightness_key, + value=brightness, ) except HomeConnectError as err: raise HomeAssistantError( @@ -253,14 +247,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): }, ) from err - self.async_entity_update() - async def async_turn_off(self, **kwargs: Any) -> None: """Switch the light off.""" - _LOGGER.debug("Switching light off for: %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.bsh_key, False + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=False, ) except HomeConnectError as err: raise HomeAssistantError( @@ -271,30 +264,50 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - self.async_entity_update() - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + keys_to_listen = [] + if self._brightness_key: + keys_to_listen.append(self._brightness_key) + if self._color_key and self._custom_color_key: + keys_to_listen.extend([self._color_key, self._custom_color_key]) + for key in keys_to_listen: + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_coordinator_update, + ( + self.appliance.info.ha_id, + EventKey(key), + ), + ) + ) + + def update_native_value(self) -> None: """Update the light's status.""" - if self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is True: - self._attr_is_on = True - elif ( - self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is False - ): - self._attr_is_on = False - else: - self._attr_is_on = None + self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value - _LOGGER.debug("Updated, new light state: %s", self._attr_is_on) - - if self._custom_color_key: - color = self.device.appliance.status.get(self._custom_color_key, {}) - - if not color: + if self._brightness_key: + brightness = cast( + float, self.appliance.settings[self._brightness_key].value + ) + self._attr_brightness = color_util.value_to_brightness( + self._brightness_scale, brightness + ) + _LOGGER.debug( + "Updated %s, new brightness: %s", self.entity_id, self._attr_brightness + ) + if self._color_key and self._custom_color_key: + color = cast(str, self.appliance.settings[self._color_key].value) + if color != self._enable_custom_color_value_key: self._attr_rgb_color = None self._attr_hs_color = None - self._attr_brightness = None else: - color_value = color.get(ATTR_VALUE)[1:] + custom_color = cast( + str, self.appliance.settings[self._custom_color_key].value + ) + color_value = custom_color[1:] rgb = color_util.rgb_hex_to_rgb_list(color_value) self._attr_rgb_color = (rgb[0], rgb[1], rgb[2]) hsv = color_util.color_RGB_to_hsv(*rgb) @@ -303,16 +316,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): self._brightness_scale, hsv[2] ) _LOGGER.debug( - "Updated, new color (%s) and new brightness (%s) ", + "Updated %s, new color (%s) and new brightness (%s) ", + self.entity_id, color_value, self._attr_brightness, ) - elif self._brightness_key: - brightness = self.device.appliance.status.get(self._brightness_key, {}) - if brightness is None: - self._attr_brightness = None - else: - self._attr_brightness = color_util.value_to_brightness( - self._brightness_scale, brightness[ATTR_VALUE] - ) - _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index e041e13d36b..905a7c67f11 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["homeconnect"], - "requirements": ["homeconnect==0.8.0"] + "requirements": ["aiohomeconnect==0.12.1"] } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 0703b4772bb..7c6101950bf 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -1,12 +1,12 @@ """Provides number enties for Home Connect.""" import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -15,66 +15,63 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - ATTR_CONSTRAINTS, - ATTR_STEPSIZE, - ATTR_UNIT, - ATTR_VALUE, DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) NUMBERS = ( NumberEntityDescription( - key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, device_class=NumberDeviceClass.TEMPERATURE, translation_key="refrigerator_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureFreezer", + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_FREEZER, device_class=NumberDeviceClass.TEMPERATURE, translation_key="freezer_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.BottleCooler.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_BOTTLE_COOLER_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="bottle_cooler_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerLeft.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_LEFT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_left_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerCommon.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_COMMON_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerRight.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_RIGHT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_right_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment2.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_2_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_2_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment3.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_3_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_3_setpoint_temperature", ), @@ -87,17 +84,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect number.""" - - def get_entities() -> list[HomeConnectNumberEntity]: - """Get a list of entities.""" - return [ - HomeConnectNumberEntity(device, description) + async_add_entities( + [ + HomeConnectNumberEntity(entry.runtime_data, appliance, description) for description in NUMBERS - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): @@ -112,10 +106,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): self.entity_id, ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self.bsh_key, - value, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=value, ) except HomeConnectError as err: raise HomeAssistantError( @@ -132,34 +126,41 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): async def async_fetch_constraints(self) -> None: """Fetch the max and min values and step for the number entity.""" try: - data = await self.hass.async_add_executor_job( - self.device.appliance.get, f"/settings/{self.bsh_key}" + data = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key) ) except HomeConnectError as err: _LOGGER.error("An error occurred: %s", err) - return - if not data or not (constraints := data.get(ATTR_CONSTRAINTS)): - return - self._attr_native_max_value = constraints.get(ATTR_MAX) - self._attr_native_min_value = constraints.get(ATTR_MIN) - self._attr_native_step = constraints.get(ATTR_STEPSIZE) - self._attr_native_unit_of_measurement = data.get(ATTR_UNIT) + else: + self.set_constraints(data) - async def async_update(self) -> None: - """Update the number setting status.""" - if not (data := self.device.appliance.status.get(self.bsh_key)): - _LOGGER.error("No value for %s", self.bsh_key) - self._attr_native_value = None + def set_constraints(self, setting: GetSetting) -> None: + """Set constraints for the number entity.""" + if not (constraints := setting.constraints): return - self._attr_native_value = data.get(ATTR_VALUE, None) - _LOGGER.debug("Updated, new value: %s", self._attr_native_value) + if constraints.max: + self._attr_native_max_value = constraints.max + if constraints.min: + self._attr_native_min_value = constraints.min + if constraints.step_size: + self._attr_native_step = constraints.step_size + else: + self._attr_native_step = 0.1 if setting.type == "Double" else 1 + def update_native_value(self) -> None: + """Update status when an event for the entity is received.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_value = cast(float, data.value) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_unit_of_measurement = data.unit + self.set_constraints(data) if ( not hasattr(self, "_attr_native_min_value") - or self._attr_native_min_value is None or not hasattr(self, "_attr_native_max_value") - or self._attr_native_max_value is None or not hasattr(self, "_attr_native_step") - or self._attr_native_step is None ): await self.async_fetch_constraints() diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index a4a5861afbe..c7408094aed 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,191 +1,28 @@ """Provides a select platform for Home Connect.""" -import contextlib -import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, ProgramKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM +from .coordinator import ( + HomeConnectApplianceData, HomeConnectConfigEntry, - bsh_key_to_translation_key, - get_dict_from_home_connect_error, -) -from .api import HomeConnectDevice -from .const import ( - APPLIANCES_WITH_PROGRAMS, - ATTR_VALUE, - BSH_ACTIVE_PROGRAM, - BSH_SELECTED_PROGRAM, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + HomeConnectCoordinator, ) from .entity import HomeConnectEntity - -_LOGGER = logging.getLogger(__name__) +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error TRANSLATION_KEYS_PROGRAMS_MAP = { - bsh_key_to_translation_key(program): program - for program in ( - "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll", - "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanMap", - "ConsumerProducts.CleaningRobot.Program.Basic.GoHome", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso", - "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee", - "ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee", - "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeGrande", - "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino", - "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato", - "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte", - "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth", - "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Doppio", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye", - "ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater", - "Dishcare.Dishwasher.Program.PreRinse", - "Dishcare.Dishwasher.Program.Auto1", - "Dishcare.Dishwasher.Program.Auto2", - "Dishcare.Dishwasher.Program.Auto3", - "Dishcare.Dishwasher.Program.Eco50", - "Dishcare.Dishwasher.Program.Quick45", - "Dishcare.Dishwasher.Program.Intensiv70", - "Dishcare.Dishwasher.Program.Normal65", - "Dishcare.Dishwasher.Program.Glas40", - "Dishcare.Dishwasher.Program.GlassCare", - "Dishcare.Dishwasher.Program.NightWash", - "Dishcare.Dishwasher.Program.Quick65", - "Dishcare.Dishwasher.Program.Normal45", - "Dishcare.Dishwasher.Program.Intensiv45", - "Dishcare.Dishwasher.Program.AutoHalfLoad", - "Dishcare.Dishwasher.Program.IntensivPower", - "Dishcare.Dishwasher.Program.MagicDaily", - "Dishcare.Dishwasher.Program.Super60", - "Dishcare.Dishwasher.Program.Kurz60", - "Dishcare.Dishwasher.Program.ExpressSparkle65", - "Dishcare.Dishwasher.Program.MachineCare", - "Dishcare.Dishwasher.Program.SteamFresh", - "Dishcare.Dishwasher.Program.MaximumCleaning", - "Dishcare.Dishwasher.Program.MixedLoad", - "LaundryCare.Dryer.Program.Cotton", - "LaundryCare.Dryer.Program.Synthetic", - "LaundryCare.Dryer.Program.Mix", - "LaundryCare.Dryer.Program.Blankets", - "LaundryCare.Dryer.Program.BusinessShirts", - "LaundryCare.Dryer.Program.DownFeathers", - "LaundryCare.Dryer.Program.Hygiene", - "LaundryCare.Dryer.Program.Jeans", - "LaundryCare.Dryer.Program.Outdoor", - "LaundryCare.Dryer.Program.SyntheticRefresh", - "LaundryCare.Dryer.Program.Towels", - "LaundryCare.Dryer.Program.Delicates", - "LaundryCare.Dryer.Program.Super40", - "LaundryCare.Dryer.Program.Shirts15", - "LaundryCare.Dryer.Program.Pillow", - "LaundryCare.Dryer.Program.AntiShrink", - "LaundryCare.Dryer.Program.MyTime.MyDryingTime", - "LaundryCare.Dryer.Program.TimeCold", - "LaundryCare.Dryer.Program.TimeWarm", - "LaundryCare.Dryer.Program.InBasket", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold20", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold30", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold60", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm30", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm40", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm60", - "LaundryCare.Dryer.Program.Dessous", - "Cooking.Common.Program.Hood.Automatic", - "Cooking.Common.Program.Hood.Venting", - "Cooking.Common.Program.Hood.DelayedShutOff", - "Cooking.Oven.Program.HeatingMode.PreHeating", - "Cooking.Oven.Program.HeatingMode.HotAir", - "Cooking.Oven.Program.HeatingMode.HotAirEco", - "Cooking.Oven.Program.HeatingMode.HotAirGrilling", - "Cooking.Oven.Program.HeatingMode.TopBottomHeating", - "Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco", - "Cooking.Oven.Program.HeatingMode.BottomHeating", - "Cooking.Oven.Program.HeatingMode.PizzaSetting", - "Cooking.Oven.Program.HeatingMode.SlowCook", - "Cooking.Oven.Program.HeatingMode.IntensiveHeat", - "Cooking.Oven.Program.HeatingMode.KeepWarm", - "Cooking.Oven.Program.HeatingMode.PreheatOvenware", - "Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial", - "Cooking.Oven.Program.HeatingMode.Desiccation", - "Cooking.Oven.Program.HeatingMode.Defrost", - "Cooking.Oven.Program.HeatingMode.Proof", - "Cooking.Oven.Program.HeatingMode.HotAir30Steam", - "Cooking.Oven.Program.HeatingMode.HotAir60Steam", - "Cooking.Oven.Program.HeatingMode.HotAir80Steam", - "Cooking.Oven.Program.HeatingMode.HotAir100Steam", - "Cooking.Oven.Program.HeatingMode.SabbathProgramme", - "Cooking.Oven.Program.Microwave.90Watt", - "Cooking.Oven.Program.Microwave.180Watt", - "Cooking.Oven.Program.Microwave.360Watt", - "Cooking.Oven.Program.Microwave.600Watt", - "Cooking.Oven.Program.Microwave.900Watt", - "Cooking.Oven.Program.Microwave.1000Watt", - "Cooking.Oven.Program.Microwave.Max", - "Cooking.Oven.Program.HeatingMode.WarmingDrawer", - "LaundryCare.Washer.Program.Cotton", - "LaundryCare.Washer.Program.Cotton.CottonEco", - "LaundryCare.Washer.Program.Cotton.Eco4060", - "LaundryCare.Washer.Program.Cotton.Colour", - "LaundryCare.Washer.Program.EasyCare", - "LaundryCare.Washer.Program.Mix", - "LaundryCare.Washer.Program.Mix.NightWash", - "LaundryCare.Washer.Program.DelicatesSilk", - "LaundryCare.Washer.Program.Wool", - "LaundryCare.Washer.Program.Sensitive", - "LaundryCare.Washer.Program.Auto30", - "LaundryCare.Washer.Program.Auto40", - "LaundryCare.Washer.Program.Auto60", - "LaundryCare.Washer.Program.Chiffon", - "LaundryCare.Washer.Program.Curtains", - "LaundryCare.Washer.Program.DarkWash", - "LaundryCare.Washer.Program.Dessous", - "LaundryCare.Washer.Program.Monsoon", - "LaundryCare.Washer.Program.Outdoor", - "LaundryCare.Washer.Program.PlushToy", - "LaundryCare.Washer.Program.ShirtsBlouses", - "LaundryCare.Washer.Program.SportFitness", - "LaundryCare.Washer.Program.Towels", - "LaundryCare.Washer.Program.WaterProof", - "LaundryCare.Washer.Program.PowerSpeed59", - "LaundryCare.Washer.Program.Super153045.Super15", - "LaundryCare.Washer.Program.Super153045.Super1530", - "LaundryCare.Washer.Program.DownDuvet.Duvet", - "LaundryCare.Washer.Program.Rinse.RinseSpinDrain", - "LaundryCare.Washer.Program.DrumClean", - "LaundryCare.WasherDryer.Program.Cotton", - "LaundryCare.WasherDryer.Program.Cotton.Eco4060", - "LaundryCare.WasherDryer.Program.Mix", - "LaundryCare.WasherDryer.Program.EasyCare", - "LaundryCare.WasherDryer.Program.WashAndDry60", - "LaundryCare.WasherDryer.Program.WashAndDry90", - ) + bsh_key_to_translation_key(program.value): cast(ProgramKey, program) + for program in ProgramKey + if program != ProgramKey.UNKNOWN } PROGRAMS_TRANSLATION_KEYS_MAP = { @@ -194,11 +31,11 @@ PROGRAMS_TRANSLATION_KEYS_MAP = { PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( SelectEntityDescription( - key=BSH_ACTIVE_PROGRAM, + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, translation_key="active_program", ), SelectEntityDescription( - key=BSH_SELECTED_PROGRAM, + key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, translation_key="selected_program", ), ) @@ -211,31 +48,12 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect select entities.""" - def get_entities() -> list[HomeConnectProgramSelectEntity]: - """Get a list of entities.""" - entities: list[HomeConnectProgramSelectEntity] = [] - programs_not_found = set() - for device in entry.runtime_data.devices: - if device.appliance.type in APPLIANCES_WITH_PROGRAMS: - with contextlib.suppress(HomeConnectError): - programs = device.appliance.get_programs_available() - if programs: - for program in programs.copy(): - if program not in PROGRAMS_TRANSLATION_KEYS_MAP: - programs.remove(program) - if program not in programs_not_found: - _LOGGER.info( - 'The program "%s" is not part of the official Home Connect API specification', - program, - ) - programs_not_found.add(program) - entities.extend( - HomeConnectProgramSelectEntity(device, programs, desc) - for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS - ) - return entities - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities( + HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) + for appliance in entry.runtime_data.data.values() + for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS + if appliance.info.type in APPLIANCES_WITH_PROGRAMS + ) class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): @@ -243,48 +61,45 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): def __init__( self, - device: HomeConnectDevice, - programs: list[str], + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, desc: SelectEntityDescription, ) -> None: """Initialize the entity.""" super().__init__( - device, + coordinator, + appliance, desc, ) self._attr_options = [ - PROGRAMS_TRANSLATION_KEYS_MAP[program] for program in programs + PROGRAMS_TRANSLATION_KEYS_MAP[program.key] + for program in appliance.programs + if program.key != ProgramKey.UNKNOWN ] - self.start_on_select = desc.key == BSH_ACTIVE_PROGRAM + self.start_on_select = desc.key == EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM + self._attr_current_option = None - async def async_update(self) -> None: - """Update the program selection status.""" - program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) - if not program: - program_translation_key = None - elif not ( - program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program) - ): - _LOGGER.debug( - 'The program "%s" is not part of the official Home Connect API specification', - program, - ) - self._attr_current_option = program_translation_key - _LOGGER.debug("Updated, new program: %s", self._attr_current_option) + def update_native_value(self) -> None: + """Set the program value.""" + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + self._attr_current_option = ( + PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value)) + if event + else None + ) async def async_select_option(self, option: str) -> None: """Select new program.""" - bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] - _LOGGER.debug( - "Starting program: %s" if self.start_on_select else "Selecting program: %s", - bsh_key, - ) - if self.start_on_select: - target = self.device.appliance.start_program - else: - target = self.device.appliance.select_program + program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] try: - await self.hass.async_add_executor_job(target, bsh_key) + if self.start_on_select: + await self.coordinator.client.start_program( + self.appliance.info.ha_id, program_key=program_key + ) + else: + await self.coordinator.client.set_selected_program( + self.appliance.info.ha_id, program_key=program_key + ) except HomeConnectError as err: if self.start_on_select: translation_key = "start_program" @@ -295,7 +110,6 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): translation_key=translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, }, ) from err - self.async_entity_update() diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 7b82ef8b676..5e7c417a172 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,10 +1,11 @@ """Provides a sensor for Home Connect.""" from dataclasses import dataclass -from datetime import datetime, timedelta -import logging +from datetime import timedelta from typing import cast +from aiohomeconnect.model import EventKey, StatusKey + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -12,39 +13,26 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify -from . import HomeConnectConfigEntry from .const import ( APPLIANCES_WITH_PROGRAMS, - ATTR_VALUE, - BSH_DOOR_STATE, - BSH_OPERATION_STATE, BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - COFFEE_EVENT_DRIP_TRAY_FULL, - COFFEE_EVENT_WATER_TANK_EMPTY, - DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, - DISHWASHER_EVENT_SALT_NEARLY_EMPTY, - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, - REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, - REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity -_LOGGER = logging.getLogger(__name__) - - EVENT_OPTIONS = ["confirmed", "off", "present"] @dataclass(frozen=True, kw_only=True) -class HomeConnectSensorEntityDescription(SensorEntityDescription): +class HomeConnectSensorEntityDescription( + SensorEntityDescription, +): """Entity Description class for sensors.""" default_value: str | None = None @@ -53,7 +41,7 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription): BSH_PROGRAM_SENSORS = ( HomeConnectSensorEntityDescription( - key="BSH.Common.Option.RemainingProgramTime", + key=EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME, device_class=SensorDeviceClass.TIMESTAMP, translation_key="program_finish_time", appliance_types=( @@ -68,13 +56,13 @@ BSH_PROGRAM_SENSORS = ( ), ), HomeConnectSensorEntityDescription( - key="BSH.Common.Option.Duration", + key=EventKey.BSH_COMMON_OPTION_DURATION, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, appliance_types=("Oven",), ), HomeConnectSensorEntityDescription( - key="BSH.Common.Option.ProgramProgress", + key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, native_unit_of_measurement=PERCENTAGE, translation_key="program_progress", appliance_types=APPLIANCES_WITH_PROGRAMS, @@ -83,7 +71,7 @@ BSH_PROGRAM_SENSORS = ( SENSORS = ( HomeConnectSensorEntityDescription( - key=BSH_OPERATION_STATE, + key=StatusKey.BSH_COMMON_OPERATION_STATE, device_class=SensorDeviceClass.ENUM, options=[ "inactive", @@ -99,7 +87,7 @@ SENSORS = ( translation_key="operation_state", ), HomeConnectSensorEntityDescription( - key=BSH_DOOR_STATE, + key=StatusKey.BSH_COMMON_DOOR_STATE, device_class=SensorDeviceClass.ENUM, options=[ "closed", @@ -109,59 +97,59 @@ SENSORS = ( translation_key="door", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffee", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterPowderCoffee", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_POWDER_COFFEE, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="powder_coffee_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWater", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER, native_unit_of_measurement=UnitOfVolume.MILLILITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWaterCups", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER_CUPS, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_cups_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterFrothyMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_FROTHY_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="frothy_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffeeAndMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE_AND_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_and_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterRistrettoEspresso", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_RISTRETTO_ESPRESSO, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="ristretto_espresso_counter", ), HomeConnectSensorEntityDescription( - key="BSH.Common.Status.BatteryLevel", + key=StatusKey.BSH_COMMON_BATTERY_LEVEL, device_class=SensorDeviceClass.BATTERY, translation_key="battery_level", ), HomeConnectSensorEntityDescription( - key="BSH.Common.Status.Video.CameraState", + key=StatusKey.BSH_COMMON_VIDEO_CAMERA_STATE, device_class=SensorDeviceClass.ENUM, options=[ "disabled", @@ -175,7 +163,7 @@ SENSORS = ( translation_key="camera_state", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.LastSelectedMap", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LAST_SELECTED_MAP, device_class=SensorDeviceClass.ENUM, options=[ "tempmap", @@ -189,7 +177,7 @@ SENSORS = ( EVENT_SENSORS = ( HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -197,7 +185,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -205,7 +193,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Refrigerator"), ), HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -213,7 +201,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -221,7 +209,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_WATER_TANK_EMPTY, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -229,7 +217,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_DRIP_TRAY_FULL, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -237,7 +225,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -245,7 +233,7 @@ EVENT_SENSORS = ( appliance_types=("Dishwasher",), ), HomeConnectSensorEntityDescription( - key=DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, + key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -262,33 +250,30 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect sensor.""" - def get_entities() -> list[SensorEntity]: - """Get a list of entities.""" - entities: list[SensorEntity] = [] - for device in entry.runtime_data.devices: - entities.extend( - HomeConnectSensor( - device, - description, - ) - for description in EVENT_SENSORS - if description.appliance_types - and device.appliance.type in description.appliance_types + entities: list[SensorEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectEventSensor( + entry.runtime_data, + appliance, + description, ) - entities.extend( - HomeConnectProgramSensor(device, desc) - for desc in BSH_PROGRAM_SENSORS - if desc.appliance_types - and device.appliance.type in desc.appliance_types - ) - entities.extend( - HomeConnectSensor(device, description) - for description in SENSORS - if description.key in device.appliance.status - ) - return entities + for description in EVENT_SENSORS + if description.appliance_types + and appliance.info.type in description.appliance_types + ) + entities.extend( + HomeConnectProgramSensor(entry.runtime_data, appliance, desc) + for desc in BSH_PROGRAM_SENSORS + if desc.appliance_types and appliance.info.type in desc.appliance_types + ) + entities.extend( + HomeConnectSensor(entry.runtime_data, appliance, description) + for description in SENSORS + if description.key in appliance.status + ) - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectSensor(HomeConnectEntity, SensorEntity): @@ -296,44 +281,25 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): entity_description: HomeConnectSensorEntityDescription - async def async_update(self) -> None: - """Update the sensor's status.""" - appliance_status = self.device.appliance.status - if ( - self.bsh_key not in appliance_status - or ATTR_VALUE not in appliance_status[self.bsh_key] - ): - self._attr_native_value = self.entity_description.default_value - _LOGGER.debug("Updated, new state: %s", self._attr_native_value) - return - status = appliance_status[self.bsh_key] + def update_native_value(self) -> None: + """Set the value of the sensor.""" + status = self.appliance.status[cast(StatusKey, self.bsh_key)].value + self._update_native_value(status) + + def _update_native_value(self, status: str | float) -> None: + """Set the value of the sensor based on the given value.""" match self.device_class: case SensorDeviceClass.TIMESTAMP: - if ATTR_VALUE not in status: - self._attr_native_value = None - elif ( - self._attr_native_value is not None - and isinstance(self._attr_native_value, datetime) - and self._attr_native_value < dt_util.utcnow() - ): - # if the date is supposed to be in the future but we're - # already past it, set state to None. - self._attr_native_value = None - else: - seconds = float(status[ATTR_VALUE]) - self._attr_native_value = dt_util.utcnow() + timedelta( - seconds=seconds - ) + self._attr_native_value = dt_util.utcnow() + timedelta( + seconds=cast(float, status) + ) case SensorDeviceClass.ENUM: # Value comes back as an enum, we only really care about the # last part, so split it off # https://developer.home-connect.com/docs/status/operation_state - self._attr_native_value = slugify( - cast(str, status.get(ATTR_VALUE)).split(".")[-1] - ) + self._attr_native_value = slugify(cast(str, status).split(".")[-1]) case _: - self._attr_native_value = status.get(ATTR_VALUE) - _LOGGER.debug("Updated, new state: %s", self._attr_native_value) + self._attr_native_value = status class HomeConnectProgramSensor(HomeConnectSensor): @@ -341,6 +307,31 @@ class HomeConnectProgramSensor(HomeConnectSensor): program_running: bool = False + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_operation_state_event, + (self.appliance.info.ha_id, EventKey.BSH_COMMON_STATUS_OPERATION_STATE), + ) + ) + + @callback + def _handle_operation_state_event(self) -> None: + """Update status when an event for the entity is received.""" + self.program_running = ( + status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) + ) is not None and status.value in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + if not self.program_running: + # reset the value when the program is not running, paused or finished + self._attr_native_value = None + self.async_write_ha_state() + @property def available(self) -> bool: """Return true if the sensor is available.""" @@ -348,20 +339,20 @@ class HomeConnectProgramSensor(HomeConnectSensor): # Otherwise, some sensors report erroneous values. return super().available and self.program_running - async def async_update(self) -> None: + def update_native_value(self) -> None: + """Update the program sensor's status.""" + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + if event: + self._update_native_value(event.value) + + +class HomeConnectEventSensor(HomeConnectSensor): + """Sensor class for Home Connect events.""" + + def update_native_value(self) -> None: """Update the sensor's status.""" - self.program_running = ( - BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status) - and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] - and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] - in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] - ) - if self.program_running: - await super().async_update() - else: - # reset the value when the program is not running, paused or finished - self._attr_native_value = None + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + if event: + self._update_native_value(event.value) + elif not self._attr_native_value: + self._attr_native_value = self.entity_description.default_value diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7ededaae5b7..d163d04a6f7 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -26,64 +26,67 @@ "message": "Appliance for device ID {device_id} not found" }, "turn_on_light": { - "message": "Error turning on {entity_id}: {description}" + "message": "Error turning on {entity_id}: {error}" }, "turn_off_light": { - "message": "Error turning off {entity_id}: {description}" + "message": "Error turning off {entity_id}: {error}" }, "set_light_brightness": { - "message": "Error setting brightness of {entity_id}: {description}" + "message": "Error setting brightness of {entity_id}: {error}" }, "select_light_custom_color": { - "message": "Error selecting custom color of {entity_id}: {description}" + "message": "Error selecting custom color of {entity_id}: {error}" }, "set_light_color": { - "message": "Error setting color of {entity_id}: {description}" + "message": "Error setting color of {entity_id}: {error}" }, "set_setting_entity": { - "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {error}" }, "set_setting": { - "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {error}" }, "turn_on": { - "message": "Error turning on {entity_id} ({key}): {description}" + "message": "Error turning on {entity_id} ({key}): {error}" }, "turn_off": { - "message": "Error turning off {entity_id} ({key}): {description}" + "message": "Error turning off {entity_id} ({key}): {error}" }, "select_program": { - "message": "Error selecting program {program}: {description}" + "message": "Error selecting program {program}: {error}" }, "start_program": { - "message": "Error starting program {program}: {description}" + "message": "Error starting program {program}: {error}" }, "pause_program": { - "message": "Error pausing program: {description}" + "message": "Error pausing program: {error}" }, "stop_program": { - "message": "Error stopping program: {description}" + "message": "Error stopping program: {error}" }, "set_options_active_program": { - "message": "Error setting options for the active program: {description}" + "message": "Error setting options for the active program: {error}" }, "set_options_selected_program": { - "message": "Error setting options for the selected program: {description}" + "message": "Error setting options for the selected program: {error}" }, "execute_command": { - "message": "Error executing command {command}: {description}" + "message": "Error executing command {command}: {error}" }, "power_on": { - "message": "Error turning on {appliance_name}: {description}" + "message": "Error turning on {appliance_name}: {error}" }, "power_off": { - "message": "Error turning off {appliance_name} with value \"{value}\": {description}" + "message": "Error turning off {appliance_name} with value \"{value}\": {error}" }, "turn_off_not_supported": { "message": "{appliance_name} does not support turning off or entering standby mode." }, "unable_to_retrieve_turn_off": { "message": "Unable to turn off {appliance_name} because its support for turning off or entering standby mode could not be determined." + }, + "fetch_api_error": { + "message": "Error obtaining data from the API: {error}" } }, "issues": { diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 1bd02e03eb1..c3a0858e0bb 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -1,10 +1,11 @@ """Provides a switch for Home Connect.""" -import contextlib import logging -from typing import Any +from typing import Any, cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, ProgramKey, SettingKey +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.program import EnumerateAvailableProgram from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -18,87 +19,83 @@ from homeassistant.helpers.issue_registry import ( async_create_issue, async_delete_issue, ) +from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - APPLIANCES_WITH_PROGRAMS, - ATTR_ALLOWED_VALUES, - ATTR_CONSTRAINTS, - ATTR_VALUE, - BSH_ACTIVE_PROGRAM, - BSH_CHILD_LOCK_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, - BSH_POWER_STATE, DOMAIN, - REFRIGERATION_DISPENSER, - REFRIGERATION_SUPERMODEFREEZER, - REFRIGERATION_SUPERMODEREFRIGERATOR, SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) -from .entity import HomeConnectDevice, HomeConnectEntity +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) +from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) SWITCHES = ( SwitchEntityDescription( - key=BSH_CHILD_LOCK_STATE, + key=SettingKey.BSH_COMMON_CHILD_LOCK, translation_key="child_lock", ), SwitchEntityDescription( - key="ConsumerProducts.CoffeeMaker.Setting.CupWarmer", + key=SettingKey.CONSUMER_PRODUCTS_COFFEE_MAKER_CUP_WARMER, translation_key="cup_warmer", ), SwitchEntityDescription( - key=REFRIGERATION_SUPERMODEFREEZER, + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER, translation_key="freezer_super_mode", ), SwitchEntityDescription( - key=REFRIGERATION_SUPERMODEREFRIGERATOR, + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR, translation_key="refrigerator_super_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.EcoMode", + key=SettingKey.REFRIGERATION_COMMON_ECO_MODE, translation_key="eco_mode", ), SwitchEntityDescription( - key="Cooking.Oven.Setting.SabbathMode", + key=SettingKey.COOKING_OVEN_SABBATH_MODE, translation_key="sabbath_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.SabbathMode", + key=SettingKey.REFRIGERATION_COMMON_SABBATH_MODE, translation_key="sabbath_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.VacationMode", + key=SettingKey.REFRIGERATION_COMMON_VACATION_MODE, translation_key="vacation_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.FreshMode", + key=SettingKey.REFRIGERATION_COMMON_FRESH_MODE, translation_key="fresh_mode", ), SwitchEntityDescription( - key=REFRIGERATION_DISPENSER, + key=SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED, translation_key="dispenser_enabled", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.Door.AssistantFridge", + key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FRIDGE, translation_key="door_assistant_fridge", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.Door.AssistantFreezer", + key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FREEZER, translation_key="door_assistant_freezer", ), ) POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( - key=BSH_POWER_STATE, + key=SettingKey.BSH_COMMON_POWER_STATE, translation_key="power", ) @@ -110,29 +107,26 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect switch.""" - def get_entities() -> list[SwitchEntity]: - """Get a list of entities.""" - entities: list[SwitchEntity] = [] - for device in entry.runtime_data.devices: - if device.appliance.type in APPLIANCES_WITH_PROGRAMS: - with contextlib.suppress(HomeConnectError): - programs = device.appliance.get_programs_available() - if programs: - entities.extend( - HomeConnectProgramSwitch(device, program) - for program in programs - ) - if BSH_POWER_STATE in device.appliance.status: - entities.append(HomeConnectPowerSwitch(device)) - entities.extend( - HomeConnectSwitch(device, description) - for description in SWITCHES - if description.key in device.appliance.status + entities: list[SwitchEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectProgramSwitch(entry.runtime_data, appliance, program) + for program in appliance.programs + if program.key != ProgramKey.UNKNOWN + ) + if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings: + entities.append( + HomeConnectPowerSwitch( + entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION + ) ) + entities.extend( + HomeConnectSwitch(entry.runtime_data, appliance, description) + for description in SWITCHES + if description.key in appliance.settings + ) - return entities - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): @@ -140,11 +134,11 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on setting.""" - - _LOGGER.debug("Turning on %s", self.entity_description.key) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.entity_description.key, True + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=True, ) except HomeConnectError as err: self._attr_available = False @@ -158,19 +152,15 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): }, ) from err - self._attr_available = True - self.async_entity_update() - async def async_turn_off(self, **kwargs: Any) -> None: """Turn off setting.""" - - _LOGGER.debug("Turning off %s", self.entity_description.key) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.entity_description.key, False + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=False, ) except HomeConnectError as err: - _LOGGER.error("Error while trying to turn off: %s", err) self._attr_available = False raise HomeAssistantError( translation_domain=DOMAIN, @@ -182,38 +172,35 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): }, ) from err - self._attr_available = True - self.async_entity_update() - - async def async_update(self) -> None: + def update_native_value(self) -> None: """Update the switch's status.""" - - self._attr_is_on = self.device.appliance.status.get( - self.entity_description.key, {} - ).get(ATTR_VALUE) - self._attr_available = True - _LOGGER.debug( - "Updated %s, new state: %s", - self.entity_description.key, - self._attr_is_on, - ) + self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): """Switch class for Home Connect.""" - def __init__(self, device: HomeConnectDevice, program_name: str) -> None: + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + program: EnumerateAvailableProgram, + ) -> None: """Initialize the entity.""" - desc = " ".join(["Program", program_name.split(".")[-1]]) - if device.appliance.type == "WasherDryer": + desc = " ".join(["Program", program.key.split(".")[-1]]) + if appliance.info.type == "WasherDryer": desc = " ".join( - ["Program", program_name.split(".")[-3], program_name.split(".")[-1]] + ["Program", program.key.split(".")[-3], program.key.split(".")[-1]] ) - super().__init__(device, SwitchEntityDescription(key=program_name)) - self._attr_name = f"{device.appliance.name} {desc}" - self._attr_unique_id = f"{device.appliance.haId}-{desc}" + super().__init__( + coordinator, + appliance, + SwitchEntityDescription(key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM), + ) + self._attr_name = f"{appliance.info.name} {desc}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc}" self._attr_has_entity_name = False - self.program_name = program_name + self.program = program async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -266,10 +253,9 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Start the program.""" - _LOGGER.debug("Tried to turn on program %s", self.program_name) try: - await self.hass.async_add_executor_job( - self.device.appliance.start_program, self.program_name + await self.coordinator.client.start_program( + self.appliance.info.ha_id, program_key=self.program.key ) except HomeConnectError as err: raise HomeAssistantError( @@ -277,16 +263,14 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): translation_key="start_program", translation_placeholders={ **get_dict_from_home_connect_error(err), - "program": self.program_name, + "program": self.program.key, }, ) from err - self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: """Stop the program.""" - _LOGGER.debug("Tried to stop program %s", self.program_name) try: - await self.hass.async_add_executor_job(self.device.appliance.stop_program) + await self.coordinator.client.stop_program(self.appliance.info.ha_id) except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -295,48 +279,25 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): **get_dict_from_home_connect_error(err), }, ) from err - self.async_entity_update() - async def async_update(self) -> None: - """Update the switch's status.""" - state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {}) - if state.get(ATTR_VALUE) == self.program_name: - self._attr_is_on = True - else: - self._attr_is_on = False - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) + def update_native_value(self) -> None: + """Update the switch's status based on if the program related to this entity is currently active.""" + event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM) + self._attr_is_on = bool(event and event.value == self.program.key) class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): """Power switch class for Home Connect.""" - power_off_state: str | None - - def __init__(self, device: HomeConnectDevice) -> None: - """Initialize the entity.""" - super().__init__( - device, - POWER_SWITCH_DESCRIPTION, - ) - if ( - power_state := device.appliance.status.get(BSH_POWER_STATE, {}).get( - ATTR_VALUE - ) - ) and power_state in [BSH_POWER_OFF, BSH_POWER_STANDBY]: - self.power_off_state = power_state - - async def async_added_to_hass(self) -> None: - """Add the entity to the hass instance.""" - await super().async_added_to_hass() - if not hasattr(self, "power_off_state"): - await self.async_fetch_power_off_state() + power_off_state: str | None | UndefinedType = UNDEFINED async def async_turn_on(self, **kwargs: Any) -> None: """Switch the device on.""" - _LOGGER.debug("Tried to switch on %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, BSH_POWER_STATE, BSH_POWER_ON + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=BSH_POWER_ON, ) except HomeConnectError as err: self._attr_is_on = False @@ -345,36 +306,36 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name, + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, }, ) from err - self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: """Switch the device off.""" - if not hasattr(self, "power_off_state"): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_retrieve_turn_off", - translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name - }, - ) + if self.power_off_state is UNDEFINED: + await self.async_fetch_power_off_state() + if self.power_off_state is UNDEFINED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_retrieve_turn_off", + translation_placeholders={ + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name + }, + ) if self.power_off_state is None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off_not_supported", translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name }, ) - _LOGGER.debug("tried to switch off %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - BSH_POWER_STATE, - self.power_off_state, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=self.power_off_state, ) except HomeConnectError as err: self._attr_is_on = True @@ -383,46 +344,51 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name, + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state, }, ) from err - self.async_entity_update() - async def async_update(self) -> None: - """Update the switch's status.""" - if ( - self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) - == BSH_POWER_ON - ): + def update_native_value(self) -> None: + """Set the value of the entity.""" + power_state = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE] + value = cast(str, power_state.value) + if value == BSH_POWER_ON: self._attr_is_on = True elif ( - hasattr(self, "power_off_state") - and self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) - == self.power_off_state + isinstance(self.power_off_state, str) + and self.power_off_state + and value == self.power_off_state ): self._attr_is_on = False + elif self.power_off_state is UNDEFINED and value in [ + BSH_POWER_OFF, + BSH_POWER_STANDBY, + ]: + self.power_off_state = value + self._attr_is_on = False else: self._attr_is_on = None - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) async def async_fetch_power_off_state(self) -> None: """Fetch the power off state.""" - try: - data = await self.hass.async_add_executor_job( - self.device.appliance.get, f"/settings/{self.bsh_key}" - ) - except HomeConnectError as err: - _LOGGER.error("An error occurred: %s", err) - return - if not data or not ( - allowed_values := data.get(ATTR_CONSTRAINTS, {}).get(ATTR_ALLOWED_VALUES) - ): + data = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE] + + if not data.constraints or not data.constraints.allowed_values: + try: + data = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + ) + except HomeConnectError as err: + _LOGGER.error("An error occurred fetching the power settings: %s", err) + return + if not data.constraints or not data.constraints.allowed_values: return - if BSH_POWER_OFF in allowed_values: + if BSH_POWER_OFF in data.constraints.allowed_values: self.power_off_state = BSH_POWER_OFF - elif BSH_POWER_STANDBY in allowed_values: + elif BSH_POWER_STANDBY in data.constraints.allowed_values: self.power_off_state = BSH_POWER_STANDBY else: self.power_off_state = None diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index c1f125cd2f7..5ed07424082 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -1,32 +1,30 @@ """Provides time enties for Home Connect.""" from datetime import time -import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - ATTR_VALUE, DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity - -_LOGGER = logging.getLogger(__name__) - +from .utils import get_dict_from_home_connect_error TIME_ENTITIES = ( TimeEntityDescription( - key="BSH.Common.Setting.AlarmClock", + key=SettingKey.BSH_COMMON_ALARM_CLOCK, translation_key="alarm_clock", ), ) @@ -39,16 +37,14 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect switch.""" - def get_entities() -> list[HomeConnectTimeEntity]: - """Get a list of entities.""" - return [ - HomeConnectTimeEntity(device, description) + async_add_entities( + [ + HomeConnectTimeEntity(entry.runtime_data, appliance, description) for description in TIME_ENTITIES - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) def seconds_to_time(seconds: int) -> time: @@ -68,17 +64,11 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): async def async_set_value(self, value: time) -> None: """Set the native value of the entity.""" - _LOGGER.debug( - "Tried to set value %s to %s for %s", - value, - self.bsh_key, - self.entity_id, - ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self.bsh_key, - time_to_seconds(value), + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=time_to_seconds(value), ) except HomeConnectError as err: raise HomeAssistantError( @@ -92,16 +82,7 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): }, ) from err - async def async_update(self) -> None: - """Update the Time setting status.""" - data = self.device.appliance.status.get(self.bsh_key) - if data is None: - _LOGGER.error("No value for %s", self.bsh_key) - self._attr_native_value = None - return - seconds = data.get(ATTR_VALUE, None) - if seconds is not None: - self._attr_native_value = seconds_to_time(seconds) - else: - self._attr_native_value = None - _LOGGER.debug("Updated, new value: %s", self._attr_native_value) + def update_native_value(self) -> None: + """Set the value of the entity.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_value = seconds_to_time(data.value) diff --git a/homeassistant/components/home_connect/utils.py b/homeassistant/components/home_connect/utils.py new file mode 100644 index 00000000000..108465072e1 --- /dev/null +++ b/homeassistant/components/home_connect/utils.py @@ -0,0 +1,29 @@ +"""Utility functions for Home Connect.""" + +import re + +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError + +RE_CAMEL_CASE = re.compile(r"(? dict[str, str]: + """Return a translation string from a Home Connect error.""" + return { + "error": str(err) + if isinstance(err, HomeConnectApiError) + else type(err).__name__ + } + + +def bsh_key_to_translation_key(bsh_key: str) -> str: + """Convert a BSH key to a translation key format. + + This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, + and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. + """ + return "_".join( + RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") + ).lower() diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py index 7a51e218a16..7fad6728a74 100644 --- a/homeassistant/components/homeassistant/const.py +++ b/homeassistant/components/homeassistant/const.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Final -import homeassistant.core as ha +from homeassistant import core as ha from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 5cd1921d8a8..e07d806d3dc 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -35,7 +35,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _TIME_TRIGGER_ENTITY = vol.All(str, cv.entity_domain(["input_datetime", "sensor"])) _TIME_AT_SCHEMA = vol.Any(cv.time, _TIME_TRIGGER_ENTITY) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index a0dfcea7616..c36738b286d 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -47,7 +47,7 @@ from homeassistant.core import ( callback, split_entity_id, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.util.unit_conversion import TemperatureConverter diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index b306c440d7b..04c75731731 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import KNOWN_DEVICES from .connection import HKDevice diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index f0fc2a40278..710f2ede52b 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -24,8 +24,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index ac0a05d24c1..5a5b2a3b8c8 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -10,7 +10,7 @@ from pyhomematic import HMConnection from pyhomematic.devicetypes.generic import HMGeneric from homeassistant.const import ATTR_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/homematic/notify.py b/homeassistant/components/homematic/notify.py index ced8ea6a951..1f89abea5cc 100644 --- a/homeassistant/components/homematic/notify.py +++ b/homeassistant/components/homematic/notify.py @@ -10,8 +10,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.template as template_helper +from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 6fc422498ab..414ba37709e 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==1.1.6"] + "requirements": ["homematicip==1.1.7"] } diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 69765ccc601..7a4dfd4916f 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import comp_entity_ids from homeassistant.helpers.service import ( async_register_admin_service, diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index a911f5398da..1f29be8e6b6 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,12 +1,18 @@ """The Homewizard integration.""" -from homewizard_energy import HomeWizardEnergy, HomeWizardEnergyV1, HomeWizardEnergyV2 +from homewizard_energy import ( + HomeWizardEnergy, + HomeWizardEnergyV1, + HomeWizardEnergyV2, + has_v2_api, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -31,6 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - clientsession=async_get_clientsession(hass), ) + await async_check_v2_support_and_create_issue(hass, entry) + coordinator = HWEnergyDeviceUpdateCoordinator(hass, api) try: await coordinator.async_config_entry_first_refresh() @@ -63,3 +71,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_check_v2_support_and_create_issue( + hass: HomeAssistant, entry: HomeWizardConfigEntry +) -> None: + """Check if the device supports v2 and create an issue if not.""" + + if not await has_v2_api(entry.data[CONF_IP_ADDRESS], async_get_clientsession(hass)): + return + + async_create_issue( + hass, + DOMAIN, + f"migrate_to_v2_api_{entry.entry_id}", + is_fixable=True, + is_persistent=False, + learn_more_url="https://home-assistant.io/integrations/homewizard/#which-button-do-i-need-to-press-to-configure-the-device", + translation_key="migrate_to_v2_api", + translation_placeholders={ + "title": entry.title, + }, + severity=IssueSeverity.WARNING, + data={"entry_id": entry.entry_id}, + ) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index fe78385381c..c94f590f000 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -5,28 +5,31 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from homewizard_energy import HomeWizardEnergyV1 -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +from homewizard_energy import ( + HomeWizardEnergy, + HomeWizardEnergyV1, + HomeWizardEnergyV2, + has_v2_api, +) +from homewizard_energy.errors import ( + DisabledError, + RequestError, + UnauthorizedError, + UnsupportedError, +) from homewizard_energy.models import Device import voluptuous as vol from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import TextSelector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import ( - CONF_API_ENABLED, - CONF_PRODUCT_NAME, - CONF_PRODUCT_TYPE, - CONF_SERIAL, - DOMAIN, - LOGGER, -) +from .const import CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, LOGGER class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): @@ -46,10 +49,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None if user_input is not None: try: - device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} + except UnauthorizedError: + # Device responded, so IP is correct. But we have to authorize + self.ip_address = user_input[CONF_IP_ADDRESS] + return await self.async_step_authorize() else: await self.async_set_unique_id( f"{device_info.product_type}_{device_info.serial}" @@ -73,22 +80,54 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step where we attempt to get a token.""" + assert self.ip_address + + # Tell device we want a token, user must now press the button within 30 seconds + # The first attempt will always fail, but this opens the window to press the button + token = await async_request_token(self.ip_address) + errors: dict[str, str] | None = None + + if token is None: + if user_input is not None: + errors = {"base": "authorization_failed"} + + return self.async_show_form(step_id="authorize", errors=errors) + + # Now we got a token, we can ask for some more info + + async with HomeWizardEnergyV2(self.ip_address, token=token) as api: + device_info = await api.device() + + data = { + CONF_IP_ADDRESS: self.ip_address, + CONF_TOKEN: token, + } + + await self.async_set_unique_id( + f"{device_info.product_type}_{device_info.serial}" + ) + self._abort_if_unique_id_configured(updates=data) + return self.async_create_entry( + title=f"{device_info.product_name}", + data=data, + ) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" + if ( - CONF_API_ENABLED not in discovery_info.properties - or CONF_PATH not in discovery_info.properties - or CONF_PRODUCT_NAME not in discovery_info.properties + CONF_PRODUCT_NAME not in discovery_info.properties or CONF_PRODUCT_TYPE not in discovery_info.properties or CONF_SERIAL not in discovery_info.properties ): return self.async_abort(reason="invalid_discovery_parameters") - if (discovery_info.properties[CONF_PATH]) != "/api/v1": - return self.async_abort(reason="unsupported_api_version") - self.ip_address = discovery_info.host self.product_type = discovery_info.properties[CONF_PRODUCT_TYPE] self.product_name = discovery_info.properties[CONF_PRODUCT_NAME] @@ -109,10 +148,12 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): This flow is triggered only by DHCP discovery of known devices. """ try: - device = await self._async_try_connect(discovery_info.ip) + device = await async_try_connect(discovery_info.ip) except RecoverableError as ex: LOGGER.error(ex) return self.async_abort(reason="unknown") + except UnauthorizedError: + return self.async_abort(reason="unsupported_api_version") await self.async_set_unique_id( f"{device.product_type}_{discovery_info.macaddress}" @@ -139,10 +180,12 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None if user_input is not None or not onboarding.async_is_onboarded(self.hass): try: - await self._async_try_connect(self.ip_address) + await async_try_connect(self.ip_address) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} + except UnauthorizedError: + return await self.async_step_authorize() else: return self.async_create_entry( title=self.product_name, @@ -172,25 +215,57 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle re-auth if API was disabled.""" - return await self.async_step_reauth_confirm() + self.ip_address = entry_data[CONF_IP_ADDRESS] - async def async_step_reauth_confirm( + # If token exists, we assume we use the v2 API and that the token has been invalidated + if entry_data.get(CONF_TOKEN): + return await self.async_step_reauth_confirm_update_token() + + # Else we assume we use the v1 API and that the API has been disabled + return await self.async_step_reauth_enable_api() + + async def async_step_reauth_enable_api( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm reauth dialog.""" + """Confirm reauth dialog, where user is asked to re-enable the HomeWizard API.""" errors: dict[str, str] | None = None if user_input is not None: reauth_entry = self._get_reauth_entry() try: - await self._async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) + await async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.hass.config_entries.async_reload(reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reauth_enable_api_successful") - return self.async_show_form(step_id="reauth_confirm", errors=errors) + return self.async_show_form(step_id="reauth_enable_api", errors=errors) + + async def async_step_reauth_confirm_update_token( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + assert self.ip_address + + errors: dict[str, str] | None = None + + token = await async_request_token(self.ip_address) + + if user_input is not None: + if token is None: + errors = {"base": "authorization_failed"} + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_TOKEN: token, + }, + ) + + return self.async_show_form( + step_id="reauth_confirm_update_token", errors=errors + ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None @@ -199,7 +274,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input: try: - device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) @@ -230,37 +305,65 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - @staticmethod - async def _async_try_connect(ip_address: str) -> Device: - """Try to connect. - Make connection with device to test the connection - and to get info for unique_id. - """ +async def async_try_connect(ip_address: str) -> Device: + """Try to connect. + + Make connection with device to test the connection + and to get info for unique_id. + """ + + energy_api: HomeWizardEnergy + + # Determine if device is v1 or v2 capable + if await has_v2_api(ip_address): + energy_api = HomeWizardEnergyV2(ip_address) + else: energy_api = HomeWizardEnergyV1(ip_address) - try: - return await energy_api.device() - except DisabledError as ex: - raise RecoverableError( - "API disabled, API must be enabled in the app", "api_not_enabled" - ) from ex + try: + return await energy_api.device() - except UnsupportedError as ex: - LOGGER.error("API version unsuppored") - raise AbortFlow("unsupported_api_version") from ex + except DisabledError as ex: + raise RecoverableError( + "API disabled, API must be enabled in the app", "api_not_enabled" + ) from ex - except RequestError as ex: - raise RecoverableError( - "Device unreachable or unexpected response", "network_error" - ) from ex + except UnsupportedError as ex: + LOGGER.error("API version unsuppored") + raise AbortFlow("unsupported_api_version") from ex - except Exception as ex: - LOGGER.exception("Unexpected exception") - raise AbortFlow("unknown_error") from ex + except RequestError as ex: + raise RecoverableError( + "Device unreachable or unexpected response", "network_error" + ) from ex - finally: - await energy_api.close() + except UnauthorizedError as ex: + raise UnauthorizedError("Unauthorized") from ex + + except Exception as ex: + LOGGER.exception("Unexpected exception") + raise AbortFlow("unknown_error") from ex + + finally: + await energy_api.close() + + +async def async_request_token(ip_address: str) -> str | None: + """Try to request a token from the device. + + This method is used to request a token from the device, + it will return None if the token request failed. + """ + + api = HomeWizardEnergyV2(ip_address) + + try: + return await api.get_token("home-assistant") + except DisabledError: + return None + finally: + await api.close() class RecoverableError(HomeAssistantError): diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index 4bed4675833..e0448edaf86 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -13,8 +13,6 @@ PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] LOGGER = logging.getLogger(__package__) # Platform config. -CONF_API_ENABLED = "api_enabled" -CONF_DATA = "data" CONF_PRODUCT_NAME = "product_name" CONF_PRODUCT_TYPE = "product_type" CONF_SERIAL = "serial" diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index 7024c760b93..92beb99ad2c 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -3,11 +3,12 @@ from __future__ import annotations from homewizard_energy import HomeWizardEnergy -from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError from homewizard_energy.models import CombinedModels as DeviceResponseEntry from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL @@ -51,6 +52,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] ex, translation_domain=DOMAIN, translation_key="api_disabled" ) from ex + except UnauthorizedError as ex: + raise ConfigEntryAuthFailed from ex + self.api_disabled = False self.data = data diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index c776cdb18f2..12bd25671e0 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -16,6 +16,7 @@ TO_REDACT = { "gas_unique_id", "id", "serial", + "token", "unique_id", "unique_meter_id", "wifi_ssid", diff --git a/homeassistant/components/homewizard/icons.json b/homeassistant/components/homewizard/icons.json index e6b1a34841f..68ebd6b84d0 100644 --- a/homeassistant/components/homewizard/icons.json +++ b/homeassistant/components/homewizard/icons.json @@ -15,6 +15,9 @@ "any_power_fail_count": { "default": "mdi:transmission-tower-off" }, + "cycles": { + "default": "mdi:battery-sync-outline" + }, "dsmr_version": { "default": "mdi:counter" }, diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 4cc94d09d74..957ed912b7d 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.1.1"], - "zeroconf": ["_hwenergy._tcp.local."] + "requirements": ["python-homewizard-energy==v8.3.0"], + "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index 423bc4dea49..008772a5a29 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -47,7 +47,10 @@ rules: devices: done diagnostics: done discovery-update-info: done - discovery: done + discovery: + status: done + comment: | + DHCP IP address updates are not supported for the v2 API. docs-data-update: done docs-examples: done docs-known-limitations: done @@ -66,10 +69,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: | - This integration does not raise any repairable issues. + repair-issues: done stale-devices: status: exempt comment: | diff --git a/homeassistant/components/homewizard/repairs.py b/homeassistant/components/homewizard/repairs.py new file mode 100644 index 00000000000..4c9a03b493f --- /dev/null +++ b/homeassistant/components/homewizard/repairs.py @@ -0,0 +1,79 @@ +"""Repairs for HomeWizard integration.""" + +from __future__ import annotations + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + +from .config_flow import async_request_token + + +class MigrateToV2ApiRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Create flow.""" + self.entry = entry + + 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 + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + + if user_input is not None: + return await self.async_step_authorize() + + return self.async_show_form( + step_id="confirm", description_placeholders={"title": self.entry.title} + ) + + async def async_step_authorize( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the authorize step of a fix flow.""" + + ip_address = self.entry.data[CONF_IP_ADDRESS] + + # Tell device we want a token, user must now press the button within 30 seconds + # The first attempt will always fail, but this opens the window to press the button + token = await async_request_token(ip_address) + errors: dict[str, str] | None = None + + if token is None: + if user_input is not None: + errors = {"base": "authorization_failed"} + + return self.async_show_form(step_id="authorize", errors=errors) + + data = {**self.entry.data, CONF_TOKEN: token} + self.hass.config_entries.async_update_entry(self.entry, data=data) + 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, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + assert data is not None + assert isinstance(data["entry_id"], str) + + if issue_id.startswith("migrate_to_v2_api_") and ( + entry := hass.config_entries.async_get_entry(data["entry_id"]) + ): + return MigrateToV2ApiRepairFlow(entry) + + raise ValueError(f"unknown repair {issue_id}") # pragma: no cover diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 8a9738e7ae7..582c65f2838 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -4,9 +4,10 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timedelta from typing import Final -from homewizard_energy.models import ExternalDevice, Measurement +from homewizard_energy.models import CombinedModels, ExternalDevice from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS, @@ -33,6 +34,7 @@ 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.util.dt import utcnow from . import HomeWizardConfigEntry from .const import DOMAIN @@ -46,9 +48,9 @@ PARALLEL_UPDATES = 1 class HomeWizardSensorEntityDescription(SensorEntityDescription): """Class describing HomeWizard sensor entities.""" - enabled_fn: Callable[[Measurement], bool] = lambda x: True - has_fn: Callable[[Measurement], bool] - value_fn: Callable[[Measurement], StateType] + enabled_fn: Callable[[CombinedModels], bool] = lambda x: True + has_fn: Callable[[CombinedModels], bool] + value_fn: Callable[[CombinedModels], StateType | datetime] @dataclass(frozen=True, kw_only=True) @@ -64,40 +66,57 @@ def to_percentage(value: float | None) -> float | None: return value * 100 if value is not None else None +def time_to_datetime(value: int | None) -> datetime | None: + """Convert seconds to datetime when value is not None.""" + return ( + utcnow().replace(microsecond=0) - timedelta(seconds=value) + if value is not None + else None + ) + + SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="smr_version", translation_key="dsmr_version", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.protocol_version is not None, - value_fn=lambda data: data.protocol_version, + has_fn=lambda data: data.measurement.protocol_version is not None, + value_fn=lambda data: data.measurement.protocol_version, ), HomeWizardSensorEntityDescription( key="meter_model", translation_key="meter_model", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.meter_model is not None, - value_fn=lambda data: data.meter_model, + has_fn=lambda data: data.measurement.meter_model is not None, + value_fn=lambda data: data.measurement.meter_model, ), HomeWizardSensorEntityDescription( key="unique_meter_id", translation_key="unique_meter_id", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.unique_id is not None, - value_fn=lambda data: data.unique_id, + has_fn=lambda data: data.measurement.unique_id is not None, + value_fn=lambda data: data.measurement.unique_id, ), HomeWizardSensorEntityDescription( key="wifi_ssid", translation_key="wifi_ssid", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.wifi_ssid is not None, - value_fn=lambda data: data.wifi_ssid, + has_fn=( + lambda data: data.system is not None and data.system.wifi_ssid is not None + ), + value_fn=( + lambda data: data.system.wifi_ssid if data.system is not None else None + ), ), HomeWizardSensorEntityDescription( key="active_tariff", translation_key="active_tariff", - has_fn=lambda data: data.tariff is not None, - value_fn=lambda data: None if data.tariff is None else str(data.tariff), + has_fn=lambda data: data.measurement.tariff is not None, + value_fn=( + lambda data: None + if data.measurement.tariff is None + else str(data.measurement.tariff) + ), device_class=SensorDeviceClass.ENUM, options=["1", "2", "3", "4"], ), @@ -108,8 +127,15 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_fn=lambda data: data.wifi_strength is not None, - value_fn=lambda data: data.wifi_strength, + has_fn=( + lambda data: data.system is not None + and data.system.wifi_strength_pct is not None + ), + value_fn=( + lambda data: data.system.wifi_strength_pct + if data.system is not None + else None + ), ), HomeWizardSensorEntityDescription( key="total_power_import_kwh", @@ -117,8 +143,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_kwh is not None, - value_fn=lambda data: data.energy_import_kwh, + has_fn=lambda data: data.measurement.energy_import_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", @@ -129,10 +155,10 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.energy_import_t1_kwh is not None - and data.energy_export_t2_kwh is not None + data.measurement.energy_import_t1_kwh is not None + and data.measurement.energy_export_t2_kwh is not None ), - value_fn=lambda data: data.energy_import_t1_kwh, + value_fn=lambda data: data.measurement.energy_import_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", @@ -141,8 +167,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t2_kwh is not None, - value_fn=lambda data: data.energy_import_t2_kwh, + has_fn=lambda data: data.measurement.energy_import_t2_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", @@ -151,8 +177,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t3_kwh is not None, - value_fn=lambda data: data.energy_import_t3_kwh, + has_fn=lambda data: data.measurement.energy_import_t3_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", @@ -161,8 +187,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t4_kwh is not None, - value_fn=lambda data: data.energy_import_t4_kwh, + has_fn=lambda data: data.measurement.energy_import_t4_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t4_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", @@ -170,9 +196,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_kwh is not None, - enabled_fn=lambda data: data.energy_export_kwh != 0, - value_fn=lambda data: data.energy_export_kwh, + has_fn=lambda data: data.measurement.energy_export_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", @@ -183,11 +209,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.energy_export_t1_kwh is not None - and data.energy_export_t2_kwh is not None + data.measurement.energy_export_t1_kwh is not None + and data.measurement.energy_export_t2_kwh is not None ), - enabled_fn=lambda data: data.energy_export_t1_kwh != 0, - value_fn=lambda data: data.energy_export_t1_kwh, + enabled_fn=lambda data: data.measurement.energy_export_t1_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", @@ -196,9 +222,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t2_kwh is not None, - enabled_fn=lambda data: data.energy_export_t2_kwh != 0, - value_fn=lambda data: data.energy_export_t2_kwh, + has_fn=lambda data: data.measurement.energy_export_t2_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t2_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", @@ -207,9 +233,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t3_kwh is not None, - enabled_fn=lambda data: data.energy_export_t3_kwh != 0, - value_fn=lambda data: data.energy_export_t3_kwh, + has_fn=lambda data: data.measurement.energy_export_t3_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t3_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", @@ -218,9 +244,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t4_kwh is not None, - enabled_fn=lambda data: data.energy_export_t4_kwh != 0, - value_fn=lambda data: data.energy_export_t4_kwh, + has_fn=lambda data: data.measurement.energy_export_t4_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t4_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t4_kwh, ), HomeWizardSensorEntityDescription( key="active_power_w", @@ -228,8 +254,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_w is not None, - value_fn=lambda data: data.power_w, + has_fn=lambda data: data.measurement.power_w is not None, + value_fn=lambda data: data.measurement.power_w, ), HomeWizardSensorEntityDescription( key="active_power_l1_w", @@ -239,8 +265,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l1_w is not None, - value_fn=lambda data: data.power_l1_w, + has_fn=lambda data: data.measurement.power_l1_w is not None, + value_fn=lambda data: data.measurement.power_l1_w, ), HomeWizardSensorEntityDescription( key="active_power_l2_w", @@ -250,8 +276,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l2_w is not None, - value_fn=lambda data: data.power_l2_w, + has_fn=lambda data: data.measurement.power_l2_w is not None, + value_fn=lambda data: data.measurement.power_l2_w, ), HomeWizardSensorEntityDescription( key="active_power_l3_w", @@ -261,8 +287,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l3_w is not None, - value_fn=lambda data: data.power_l3_w, + has_fn=lambda data: data.measurement.power_l3_w is not None, + value_fn=lambda data: data.measurement.power_l3_w, ), HomeWizardSensorEntityDescription( key="active_voltage_v", @@ -270,8 +296,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_v is not None, - value_fn=lambda data: data.voltage_v, + has_fn=lambda data: data.measurement.voltage_v is not None, + value_fn=lambda data: data.measurement.voltage_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l1_v", @@ -281,8 +307,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l1_v is not None, - value_fn=lambda data: data.voltage_l1_v, + has_fn=lambda data: data.measurement.voltage_l1_v is not None, + value_fn=lambda data: data.measurement.voltage_l1_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l2_v", @@ -292,8 +318,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l2_v is not None, - value_fn=lambda data: data.voltage_l2_v, + has_fn=lambda data: data.measurement.voltage_l2_v is not None, + value_fn=lambda data: data.measurement.voltage_l2_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l3_v", @@ -303,8 +329,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l3_v is not None, - value_fn=lambda data: data.voltage_l3_v, + has_fn=lambda data: data.measurement.voltage_l3_v is not None, + value_fn=lambda data: data.measurement.voltage_l3_v, ), HomeWizardSensorEntityDescription( key="active_current_a", @@ -312,8 +338,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_a is not None, - value_fn=lambda data: data.current_a, + has_fn=lambda data: data.measurement.current_a is not None, + value_fn=lambda data: data.measurement.current_a, ), HomeWizardSensorEntityDescription( key="active_current_l1_a", @@ -323,8 +349,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l1_a is not None, - value_fn=lambda data: data.current_l1_a, + has_fn=lambda data: data.measurement.current_l1_a is not None, + value_fn=lambda data: data.measurement.current_l1_a, ), HomeWizardSensorEntityDescription( key="active_current_l2_a", @@ -334,8 +360,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l2_a is not None, - value_fn=lambda data: data.current_l2_a, + has_fn=lambda data: data.measurement.current_l2_a is not None, + value_fn=lambda data: data.measurement.current_l2_a, ), HomeWizardSensorEntityDescription( key="active_current_l3_a", @@ -345,8 +371,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l3_a is not None, - value_fn=lambda data: data.current_l3_a, + has_fn=lambda data: data.measurement.current_l3_a is not None, + value_fn=lambda data: data.measurement.current_l3_a, ), HomeWizardSensorEntityDescription( key="active_frequency_hz", @@ -354,8 +380,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.frequency_hz is not None, - value_fn=lambda data: data.frequency_hz, + has_fn=lambda data: data.measurement.frequency_hz is not None, + value_fn=lambda data: data.measurement.frequency_hz, ), HomeWizardSensorEntityDescription( key="active_apparent_power_va", @@ -363,8 +389,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_va is not None, - value_fn=lambda data: data.apparent_power_va, + has_fn=lambda data: data.measurement.apparent_power_va is not None, + value_fn=lambda data: data.measurement.apparent_power_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l1_va", @@ -374,8 +400,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l1_va is not None, - value_fn=lambda data: data.apparent_power_l1_va, + has_fn=lambda data: data.measurement.apparent_power_l1_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l1_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l2_va", @@ -385,8 +411,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l2_va is not None, - value_fn=lambda data: data.apparent_power_l2_va, + has_fn=lambda data: data.measurement.apparent_power_l2_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l2_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l3_va", @@ -396,8 +422,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l3_va is not None, - value_fn=lambda data: data.apparent_power_l3_va, + has_fn=lambda data: data.measurement.apparent_power_l3_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l3_va, ), HomeWizardSensorEntityDescription( key="active_reactive_power_var", @@ -405,8 +431,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_var is not None, - value_fn=lambda data: data.reactive_power_var, + has_fn=lambda data: data.measurement.reactive_power_var is not None, + value_fn=lambda data: data.measurement.reactive_power_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l1_var", @@ -416,8 +442,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l1_var is not None, - value_fn=lambda data: data.reactive_power_l1_var, + has_fn=lambda data: data.measurement.reactive_power_l1_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l1_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l2_var", @@ -427,8 +453,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l2_var is not None, - value_fn=lambda data: data.reactive_power_l2_var, + has_fn=lambda data: data.measurement.reactive_power_l2_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l2_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l3_var", @@ -438,8 +464,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l3_var is not None, - value_fn=lambda data: data.reactive_power_l3_var, + has_fn=lambda data: data.measurement.reactive_power_l3_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l3_var, ), HomeWizardSensorEntityDescription( key="active_power_factor", @@ -447,8 +473,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor is not None, - value_fn=lambda data: to_percentage(data.power_factor), + has_fn=lambda data: data.measurement.power_factor is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor), ), HomeWizardSensorEntityDescription( key="active_power_factor_l1", @@ -458,8 +484,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l1 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l1), + has_fn=lambda data: data.measurement.power_factor_l1 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l1), ), HomeWizardSensorEntityDescription( key="active_power_factor_l2", @@ -469,8 +495,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l2 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l2), + has_fn=lambda data: data.measurement.power_factor_l2 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l2), ), HomeWizardSensorEntityDescription( key="active_power_factor_l3", @@ -480,94 +506,94 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l3 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l3), + has_fn=lambda data: data.measurement.power_factor_l3 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l3), ), HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l1_count is not None, - value_fn=lambda data: data.voltage_sag_l1_count, + has_fn=lambda data: data.measurement.voltage_sag_l1_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l2_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l2_count is not None, - value_fn=lambda data: data.voltage_sag_l2_count, + has_fn=lambda data: data.measurement.voltage_sag_l2_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l3_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l3_count is not None, - value_fn=lambda data: data.voltage_sag_l3_count, + has_fn=lambda data: data.measurement.voltage_sag_l3_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l3_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l1_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l1_count is not None, - value_fn=lambda data: data.voltage_swell_l1_count, + has_fn=lambda data: data.measurement.voltage_swell_l1_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l2_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l2_count is not None, - value_fn=lambda data: data.voltage_swell_l2_count, + has_fn=lambda data: data.measurement.voltage_swell_l2_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l3_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l3_count is not None, - value_fn=lambda data: data.voltage_swell_l3_count, + has_fn=lambda data: data.measurement.voltage_swell_l3_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l3_count, ), HomeWizardSensorEntityDescription( key="any_power_fail_count", translation_key="any_power_fail_count", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.any_power_fail_count is not None, - value_fn=lambda data: data.any_power_fail_count, + has_fn=lambda data: data.measurement.any_power_fail_count is not None, + value_fn=lambda data: data.measurement.any_power_fail_count, ), HomeWizardSensorEntityDescription( key="long_power_fail_count", translation_key="long_power_fail_count", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.long_power_fail_count is not None, - value_fn=lambda data: data.long_power_fail_count, + has_fn=lambda data: data.measurement.long_power_fail_count is not None, + value_fn=lambda data: data.measurement.long_power_fail_count, ), HomeWizardSensorEntityDescription( key="active_power_average_w", translation_key="active_power_average_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - has_fn=lambda data: data.average_power_15m_w is not None, - value_fn=lambda data: data.average_power_15m_w, + has_fn=lambda data: data.measurement.average_power_15m_w is not None, + value_fn=lambda data: data.measurement.average_power_15m_w, ), HomeWizardSensorEntityDescription( key="monthly_power_peak_w", translation_key="monthly_power_peak_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - has_fn=lambda data: data.monthly_power_peak_w is not None, - value_fn=lambda data: data.monthly_power_peak_w, + has_fn=lambda data: data.measurement.monthly_power_peak_w is not None, + value_fn=lambda data: data.measurement.monthly_power_peak_w, ), HomeWizardSensorEntityDescription( key="active_liter_lpm", translation_key="active_liter_lpm", native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, - has_fn=lambda data: data.active_liter_lpm is not None, - value_fn=lambda data: data.active_liter_lpm, + has_fn=lambda data: data.measurement.active_liter_lpm is not None, + value_fn=lambda data: data.measurement.active_liter_lpm, ), HomeWizardSensorEntityDescription( key="total_liter_m3", @@ -575,8 +601,39 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_liter_m3 is not None, - value_fn=lambda data: data.total_liter_m3, + has_fn=lambda data: data.measurement.total_liter_m3 is not None, + value_fn=lambda data: data.measurement.total_liter_m3, + ), + HomeWizardSensorEntityDescription( + key="state_of_charge_pct", + translation_key="state_of_charge_pct", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + has_fn=lambda data: data.measurement.state_of_charge_pct is not None, + value_fn=lambda data: data.measurement.state_of_charge_pct, + ), + HomeWizardSensorEntityDescription( + key="cycles", + translation_key="cycles", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + has_fn=lambda data: data.measurement.cycles is not None, + value_fn=lambda data: data.measurement.cycles, + ), + HomeWizardSensorEntityDescription( + key="uptime", + translation_key="uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=( + lambda data: data.system is not None and data.system.uptime_s is not None + ), + value_fn=( + lambda data: time_to_datetime(data.system.uptime_s) if data.system else None + ), ), ) @@ -622,16 +679,15 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" - measurement = entry.runtime_data.data.measurement - # Initialize default sensors entities: list = [ HomeWizardSensorEntity(entry.runtime_data, description) for description in SENSORS - if description.has_fn(measurement) + if description.has_fn(entry.runtime_data.data) ] # Initialize external devices + measurement = entry.runtime_data.data.measurement if measurement.external_devices is not None: for unique_id, device in measurement.external_devices.items(): if device.type is not None and ( @@ -661,13 +717,13 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" - if not description.enabled_fn(self.coordinator.data.measurement): + if not description.enabled_fn(self.coordinator.data): self._attr_entity_registry_enabled_default = False @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime | None: """Return the sensor value.""" - return self.entity_description.value_fn(self.coordinator.data.measurement) + return self.entity_description.value_fn(self.coordinator.data) @property def available(self) -> bool: diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 4309664c4c8..02b18d5fa4e 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -15,9 +15,17 @@ "title": "Confirm", "description": "Do you want to set up {product_type} ({serial}) at {ip_address}?" }, - "reauth_confirm": { + "reauth_enable_api": { "description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings." }, + "reauth_confirm_update_token": { + "title": "Re-authenticate", + "description": "[%key:component::homewizard::config::step::authorize::description%]" + }, + "authorize": { + "title": "Authorize", + "description": "Press the button on the HomeWizard Energy device, then select the button below." + }, "reconfigure": { "description": "Update configuration for {title}.", "data": { @@ -30,7 +38,8 @@ }, "error": { "api_not_enabled": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings.", - "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network" + "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network", + "authorization_failed": "Failed to authorize, make sure to press the button of the device within 30 seconds" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -38,7 +47,8 @@ "device_not_supported": "This device is not supported", "unknown_error": "[%key:common::config_flow::error::unknown%]", "unsupported_api_version": "Detected unsupported API version", - "reauth_successful": "Enabling API was successful", + "reauth_enable_api_successful": "Enabling API was successful", + "reauth_successful": "Authorization successful", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "wrong_device": "The configured device is not the same found on this IP address." } @@ -121,6 +131,15 @@ }, "total_liter_m3": { "name": "Total water usage" + }, + "cycles": { + "name": "Battery cycles" + }, + "state_of_charge_pct": { + "name": "State of charge" + }, + "uptime": { + "name": "Uptime" } }, "switch": { @@ -139,5 +158,25 @@ "communication_error": { "message": "An error occurred while communicating with HomeWizard device" } + }, + "issues": { + "migrate_to_v2_api": { + "title": "Update authentication method", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::homewizard::issues::migrate_to_v2_api::title%]", + "description": "Your {title} now supports a more secure and feature-rich communication method. To take advantage of this, you need to reconfigure the integration.\n\nSelect **Submit** to start the reconfiguration." + }, + "authorize": { + "title": "[%key:component::homewizard::config::step::authorize::title%]", + "description": "[%key:component::homewizard::config::step::authorize::description%]" + } + }, + "error": { + "authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]" + } + } + } } } diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 0878703e4d5..8ebb56433b1 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -59,7 +59,7 @@ SWITCHES = [ key="cloud_connection", translation_key="cloud_connection", entity_category=EntityCategory.CONFIG, - create_fn=lambda _: True, + create_fn=lambda x: x.device.supports_cloud_enable(), available_fn=lambda x: x.system is not None, is_on_fn=lambda x: x.system.cloud_enabled if x.system else None, set_fn=lambda api, active: api.system(cloud_enabled=active), diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index e9e8c969b61..75fdeb4f8cc 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 36a4f497601..7fa102c6599 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.30"] + "requirements": ["AIOSomecomfort==0.0.32"] } diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py index ba3ca5e2e35..d1b733ab84a 100644 --- a/homeassistant/components/horizon/media_player.py +++ b/homeassistant/components/horizon/media_player.py @@ -21,7 +21,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py index 0eeb443cf2d..b4263f53d24 100644 --- a/homeassistant/components/hp_ilo/sensor.py +++ b/homeassistant/components/hp_ilo/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 95cdee9ab9e..8ee27039441 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -37,8 +37,12 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import frame, issue_registry as ir, storage -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + frame, + issue_registry as ir, + storage, +) from homeassistant.helpers.http import ( KEY_ALLOW_CONFIGURED_CORS, KEY_AUTHENTICATED, # noqa: F401 diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index b5093999836..821d44eebaa 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -26,7 +26,7 @@ import voluptuous as vol from homeassistant.config import load_yaml_config_file from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio from homeassistant.util import dt as dt_util, yaml as yaml_util diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py index 30555339f19..de6da161fba 100644 --- a/homeassistant/components/hue/services.py +++ b/homeassistant/components/hue/services.py @@ -9,7 +9,7 @@ from aiohue import HueBridgeV1, HueBridgeV2 import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import verify_domain_control from .bridge import HueBridge diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index c7f966ce9f2..17cd20b55aa 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -24,9 +24,9 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.util import color as color_util from ..bridge import HueBridge diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index 06440480277..9ff36412418 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -19,8 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType, VolDictType diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py index 425fdbcc679..490143c728d 100644 --- a/homeassistant/components/humidifier/intent.py +++ b/homeassistant/components/humidifier/intent.py @@ -6,8 +6,7 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, STATE_OFF from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, intent from . import ( ATTR_AVAILABLE_MODES, diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index d9358db2753..b4bbc37b1e8 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -11,7 +11,7 @@ from aiopvapi.shades import Shades from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import DOMAIN, HUB_EXCEPTIONS from .coordinator import PowerviewShadeUpdateCoordinator diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index ba572ecefce..f2a841a7d0e 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -4,7 +4,7 @@ import logging from aiopvapi.resources.shade import BaseShade, ShadePosition -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 536b8f18259..d76ccef7cab 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -17,8 +17,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_OFFSET, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import CONF_FILTER, CONF_REAL_TIME, CONF_STATION, DOMAIN from .hub import GTIHub diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index ea5a5801e69..ee5a8a66610 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,9 +1,9 @@ """Support for Hydrawise cloud.""" -from pydrawise import auth, client +from pydrawise import auth, hybrid from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -21,16 +21,21 @@ PLATFORMS: list[Platform] = [ Platform.VALVE, ] +_REQUIRED_AUTH_KEYS = (CONF_USERNAME, CONF_PASSWORD, CONF_API_KEY) + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Hydrawise from a config entry.""" - if CONF_USERNAME not in config_entry.data or CONF_PASSWORD not in config_entry.data: - # The GraphQL API requires username and password to authenticate. If either is - # missing, reauth is required. + if any(k not in config_entry.data for k in _REQUIRED_AUTH_KEYS): + # If we are missing any required authentication keys, trigger a reauth flow. raise ConfigEntryAuthFailed - hydrawise = client.Hydrawise( - auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]), + hydrawise = hybrid.HybridClient( + auth.HybridAuth( + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + config_entry.data[CONF_API_KEY], + ), app_id=APP_ID, ) diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index ed21e96cd0b..3a61908ee2d 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -6,25 +6,32 @@ from collections.abc import Mapping from typing import Any from aiohttp import ClientError -from pydrawise import auth as pydrawise_auth, client +from pydrawise import auth as pydrawise_auth, hybrid from pydrawise.exceptions import NotAuthorizedError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from .const import APP_ID, DOMAIN, LOGGER STEP_USER_DATA_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, + } +) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_PASSWORD): str, vol.Required(CONF_API_KEY): str} ) -STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Hydrawise.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -34,14 +41,19 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): return self._show_user_form({}) username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] - unique_id, errors = await _authenticate(username, password) + api_key = user_input[CONF_API_KEY] + unique_id, errors = await _authenticate(username, password, api_key) if errors: return self._show_user_form(errors) await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return self.async_create_entry( title=username, - data={CONF_USERNAME: username, CONF_PASSWORD: password}, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_API_KEY: api_key, + }, ) def _show_user_form(self, errors: dict[str, str]) -> ConfigFlowResult: @@ -65,14 +77,20 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() username = reauth_entry.data[CONF_USERNAME] password = user_input[CONF_PASSWORD] - user_id, errors = await _authenticate(username, password) + api_key = user_input[CONF_API_KEY] + user_id, errors = await _authenticate(username, password, api_key) if user_id is None: return self._show_reauth_form(errors) await self.async_set_unique_id(user_id) self._abort_if_unique_id_mismatch(reason="wrong_account") return self.async_update_reload_and_abort( - reauth_entry, data={CONF_USERNAME: username, CONF_PASSWORD: password} + reauth_entry, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_API_KEY: api_key, + }, ) def _show_reauth_form(self, errors: dict[str, str]) -> ConfigFlowResult: @@ -82,14 +100,14 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): async def _authenticate( - username: str, password: str + username: str, password: str, api_key: str ) -> tuple[str | None, dict[str, str]]: """Authenticate with the Hydrawise API.""" unique_id = None errors: dict[str, str] = {} - auth = pydrawise_auth.Auth(username, password) + auth = pydrawise_auth.HybridAuth(username, password, api_key) try: - await auth.token() + await auth.check() except NotAuthorizedError: errors["base"] = "invalid_auth" except TimeoutError: @@ -99,7 +117,7 @@ async def _authenticate( return unique_id, errors try: - api = client.Hydrawise(auth, app_id=APP_ID) + api = hybrid.HybridClient(auth, app_id=APP_ID) # Don't fetch zones because we don't need them yet. user = await api.get_user(fetch_zones=False) except TimeoutError: diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index e82a4ec1588..4721a9fb154 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from pydrawise import Hydrawise +from pydrawise import HydrawiseBase from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone from homeassistant.core import HomeAssistant @@ -38,7 +38,7 @@ class HydrawiseUpdateCoordinators: class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): """Base class for Hydrawise Data Update Coordinators.""" - api: Hydrawise + api: HydrawiseBase class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): @@ -49,7 +49,7 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): integration are updated in a timely manner. """ - def __init__(self, hass: HomeAssistant, api: Hydrawise) -> None: + def __init__(self, hass: HomeAssistant, api: HydrawiseBase) -> None: """Initialize HydrawiseDataUpdateCoordinator.""" super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL) self.api = api @@ -82,7 +82,7 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): def __init__( self, hass: HomeAssistant, - api: Hydrawise, + api: HydrawiseBase, main_coordinator: HydrawiseMainDataUpdateCoordinator, ) -> None: """Initialize HydrawiseWaterUseDataUpdateCoordinator.""" diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index 74c63cbe758..47543aa2f8f 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -6,14 +6,22 @@ "description": "Please provide the username and password for your Hydrawise cloud account:", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "You can generate an API Key in the 'Account Details' section of the Hydrawise app" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Hydrawise integration needs to re-authenticate your account", "data": { - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::hydrawise::config::step::user::data_description::api_key%]" } } }, diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 1addaf1ec92..62cd81a0481 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any -from pydrawise import Hydrawise, Zone +from pydrawise import HydrawiseBase, Zone from homeassistant.components.switch import ( SwitchDeviceClass, @@ -28,8 +28,8 @@ from .entity import HydrawiseEntity class HydrawiseSwitchEntityDescription(SwitchEntityDescription): """Describes Hydrawise binary sensor.""" - turn_on_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]] - turn_off_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]] + turn_on_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]] value_fn: Callable[[Zone], bool] diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 045fbd986cc..72e76ef8667 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -28,7 +28,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from . import create_hyperion_client diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 5fa129ce7ad..40d093430a5 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -26,7 +26,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import ( get_hyperion_device_id, diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index 1069c6696fc..047281bdb27 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -32,8 +32,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import debounce, entity_registry as er, update_coordinator -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + debounce, + entity_registry as er, + update_coordinator, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ibeacon/config_flow.py b/homeassistant/components/ibeacon/config_flow.py index c00398e39b0..5850a623ad8 100644 --- a/homeassistant/components/ibeacon/config_flow.py +++ b/homeassistant/components/ibeacon/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import CONF_ALLOW_NAMELESS_UUIDS, DOMAIN diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 5bdfd00dc60..4ed66be6a4b 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.util import slugify diff --git a/homeassistant/components/idteck_prox/__init__.py b/homeassistant/components/idteck_prox/__init__.py index 7b92499a197..68969f1eced 100644 --- a/homeassistant/components/idteck_prox/__init__.py +++ b/homeassistant/components/idteck_prox/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index e3db68e2302..c5682e5a8d9 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -15,8 +15,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py index 739352485bd..f36fe8e672b 100644 --- a/homeassistant/components/ifttt/alarm_control_panel.py +++ b/homeassistant/components/ifttt/alarm_control_panel.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_OPTIMISTIC, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/iglo/light.py b/homeassistant/components/iglo/light.py index 0d20761c6e5..d356ad05541 100644 --- a/homeassistant/components/iglo/light.py +++ b/homeassistant/components/iglo/light.py @@ -20,10 +20,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util DEFAULT_NAME = "iGlo Light" DEFAULT_PORT = 8080 diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py index 7076d6a77a9..e99f2b23ca0 100644 --- a/homeassistant/components/ign_sismologia/geo_location.py +++ b/homeassistant/components/ign_sismologia/geo_location.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index d443ac335db..0fc62301984 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .auto_setup import autosetup_ihc_products diff --git a/homeassistant/components/ihc/auto_setup.py b/homeassistant/components/ihc/auto_setup.py index 2d6e59131cd..9b711875167 100644 --- a/homeassistant/components/ihc/auto_setup.py +++ b/homeassistant/components/ihc/auto_setup.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.config import load_yaml_config_file from homeassistant.const import CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from .const import ( AUTO_SETUP_YAML, diff --git a/homeassistant/components/ihc/manual_setup.py b/homeassistant/components/ihc/manual_setup.py index c453494e263..f17920145e7 100644 --- a/homeassistant/components/ihc/manual_setup.py +++ b/homeassistant/components/ihc/manual_setup.py @@ -15,8 +15,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/ihc/service_functions.py b/homeassistant/components/ihc/service_functions.py index 61eba4791ac..d5507328e73 100644 --- a/homeassistant/components/ihc/service_functions.py +++ b/homeassistant/components/ihc/service_functions.py @@ -3,7 +3,7 @@ import voluptuous as vol from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_CONTROLLER_ID, diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 1cf2de278d1..644d335bbca 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -28,7 +28,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import ( diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 0ac8d39813b..06b6bb7a57f 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 5e9cf8c4e0e..2bf28d13fd2 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index f62edf1451f..5349f249ab3 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, ServiceValidationError, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_ENABLE_PUSH, DOMAIN diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index f4d752bfa48..d02b1d27554 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -1,6 +1,6 @@ { "domain": "incomfort", - "name": "Intergas InComfort/Intouch Lan2RF gateway", + "name": "Intergas gateway", "codeowners": ["@jbouwh"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 4c47d4c57ad..15e28b6e0b9 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -2,20 +2,20 @@ "config": { "step": { "user": { - "description": "Set up new Intergas InComfort Lan2RF Gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.", + "description": "Set up new Intergas gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "Hostname or IP-address of the Intergas InComfort Lan2RF Gateway.", + "host": "Hostname or IP-address of the Intergas gateway.", "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + "password": "The password to log into the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." } }, "dhcp_auth": { - "title": "Set up Intergas InComfort Lan2RF Gateway", + "title": "Set up Intergas gateway", "description": "Please enter authentication details for gateway {host}", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -23,12 +23,12 @@ }, "data_description": { "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + "password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices." } }, "dhcp_confirm": { - "title": "Set up Intergas InComfort Lan2RF Gateway", - "description": "Do you want to set up the discovered Intergas InComfort Lan2RF Gateway ({host})?" + "title": "Set up Intergas gateway", + "description": "Do you want to set up the discovered Intergas gateway ({host})?" }, "reauth_confirm": { "data": { @@ -48,9 +48,9 @@ "error": { "auth_error": "Invalid credentials.", "no_heaters": "No heaters found.", - "not_found": "No Lan2RF gateway found.", - "timeout_error": "Time out when connecting to Lan2RF gateway.", - "unknown": "Unknown error when connecting to Lan2RF gateway." + "not_found": "No gateway found.", + "timeout_error": "Time out when connecting to the gateway.", + "unknown": "Unknown error when connecting to the gateway." } }, "exceptions": { @@ -70,7 +70,7 @@ "options": { "step": { "init": { - "title": "Intergas InComfort Lan2RF Gateway options", + "title": "Intergas gateway options", "data": { "legacy_setpoint_status": "Legacy setpoint handling" }, diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index b1c0cc53d61..95a94cf8fa0 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -40,8 +40,11 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import event as event_helper, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + event as event_helper, + state as state_helper, +) from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py index cab9d1e4c41..78cb7908eec 100644 --- a/homeassistant/components/influxdb/const.py +++ b/homeassistant/components/influxdb/const.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_DB_NAME = "database" CONF_BUCKET = "bucket" diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index cc601888f56..30319416a61 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady, TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index 54457ab2fb7..a0a7514eaaf 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index 69ff235948d..12bc98f7674 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 428ffccb7c1..60f882c2726 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -18,8 +18,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index d52bfedfe77..3352b55442a 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index a117cf0a867..171998c02bc 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -27,8 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 7d8f6663673..998bf35cd82 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -18,8 +18,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py index 4d36f1d71e5..ac633e2a457 100644 --- a/homeassistant/components/insteon/api/properties.py +++ b/homeassistant/components/insteon/api/properties.py @@ -22,7 +22,7 @@ import voluptuous_serialize from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from ..const import ( DEVICE_ADDRESS, diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 4cf8d49d170..70458dc5d6f 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, ENTITY_MATCH_ALL, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_CAT, diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 1a1f58a6b80..a04a6ee6377 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -32,8 +32,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index b5bd0aea58f..cf70a97f52a 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -18,7 +18,7 @@ from homeassistant.components.notify import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import device_name_for_push_id, devices_with_push, enabled_push_ids diff --git a/homeassistant/components/iperf3/__init__.py b/homeassistant/components/iperf3/__init__.py index a621f1fb27e..3fbe447f9fb 100644 --- a/homeassistant/components/iperf3/__init__.py +++ b/homeassistant/components/iperf3/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfDataRate, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py index a0ecf1f582e..9b0fbe29736 100644 --- a/homeassistant/components/ipma/config_flow.py +++ b/homeassistant/components/ipma/config_flow.py @@ -10,8 +10,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 2765a14b7a3..cd0ccccaece 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json index 94f20b4d93c..caa176ab6b6 100644 --- a/homeassistant/components/iskra/manifest.json +++ b/homeassistant/components/iskra/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyiskra"], - "requirements": ["pyiskra==0.1.14"] + "requirements": ["pyiskra==0.1.15"] } diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 7005bee3585..35903afa393 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_CALC_METHOD, diff --git a/homeassistant/components/israel_rail/coordinator.py b/homeassistant/components/israel_rail/coordinator.py index d707f8c5ea6..b022e3fd790 100644 --- a/homeassistant/components/israel_rail/coordinator.py +++ b/homeassistant/components/israel_rail/coordinator.py @@ -13,7 +13,7 @@ from israelrailapi.train_station import station_name_to_id from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DEFAULT_SCAN_INTERVAL, DEPARTURES_COUNT, DOMAIN diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index d2862054971..738c7e2d5ad 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -21,8 +21,11 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_validation as cv -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, +) from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from .const import ( diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 1cd46446ed6..6546aec6efa 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.service import entity_service_call diff --git a/homeassistant/components/isy994/util.py b/homeassistant/components/isy994/util.py index ed1a5abca8b..ca5c5ea46a9 100644 --- a/homeassistant/components/isy994/util.py +++ b/homeassistant/components/isy994/util.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import _LOGGER, DOMAIN diff --git a/homeassistant/components/itach/remote.py b/homeassistant/components/itach/remote.py index 986dbfb8b95..235d290cccb 100644 --- a/homeassistant/components/itach/remote.py +++ b/homeassistant/components/itach/remote.py @@ -24,7 +24,7 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index 0f241041c0d..92e3aefe975 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index c00f2d1f83f..1fd9a03e05f 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -6,7 +6,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DATA_CONFIG, IZONE diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 9fd1371f8a8..85519bf37b0 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import event from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index d3e70eb411c..5e02435ed06 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import SUN_EVENT_SUNSET, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import get_astral_event_date -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity diff --git a/homeassistant/components/joaoapps_join/__init__.py b/homeassistant/components/joaoapps_join/__init__.py index f537866054f..89b5748a714 100644 --- a/homeassistant/components/joaoapps_join/__init__.py +++ b/homeassistant/components/joaoapps_join/__init__.py @@ -15,7 +15,7 @@ import voluptuous as vol from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/joaoapps_join/notify.py b/homeassistant/components/joaoapps_join/notify.py index 7fab894b0e4..a3432b96b13 100644 --- a/homeassistant/components/joaoapps_join/notify.py +++ b/homeassistant/components/joaoapps_join/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kankun/switch.py b/homeassistant/components/kankun/switch.py index cd91b7660c8..51bddebeb77 100644 --- a/homeassistant/components/kankun/switch.py +++ b/homeassistant/components/kankun/switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/keba/__init__.py b/homeassistant/components/keba/__init__.py index 34eb7c99166..2c372cf1a25 100644 --- a/homeassistant/components/keba/__init__.py +++ b/homeassistant/components/keba/__init__.py @@ -8,8 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index efd2a88b1f8..0f5166e16dd 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, ROUTER from .router import KeeneticRouter diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py index 5a4f32a05cd..8c3079b910d 100644 --- a/homeassistant/components/keenetic_ndms2/router.py +++ b/homeassistant/components/keenetic_ndms2/router.py @@ -22,7 +22,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_CONSIDER_HOME, diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index 5831a770466..979aeb73e45 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index b405f36bb23..f543ae72972 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aionotify", "evdev"], "quality_scale": "legacy", - "requirements": ["evdev==1.6.1", "asyncinotify==4.0.2"] + "requirements": ["evdev==1.6.1", "asyncinotify==4.2.0"] } diff --git a/homeassistant/components/kira/__init__.py b/homeassistant/components/kira/__init__.py index 52618a125b6..092fdf8398c 100644 --- a/homeassistant/components/kira/__init__.py +++ b/homeassistant/components/kira/__init__.py @@ -21,8 +21,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "kira" diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 88d0c868636..09a72fc529c 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py index c4a045aeefc..44ac0456105 100644 --- a/homeassistant/components/kitchen_sink/backup.py +++ b/homeassistant/components/kitchen_sink/backup.py @@ -51,7 +51,7 @@ class KitchenSinkBackupAgent(BackupAgent): def __init__(self, name: str) -> None: """Initialize the kitchen sink backup sync agent.""" super().__init__() - self.name = name + self.name = self.unique_id = name self._uploads = [ AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], diff --git a/homeassistant/components/kitchen_sink/weather.py b/homeassistant/components/kitchen_sink/weather.py index 8a12cb4bdb9..e94e823c692 100644 --- a/homeassistant/components/kitchen_sink/weather.py +++ b/homeassistant/components/kitchen_sink/weather.py @@ -28,7 +28,7 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_CLOUDY: [], diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index 887747d4ca4..d378fcbcbed 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 96438df96d7..c629860351c 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -18,14 +18,28 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import ATTR_COUNTER, ATTR_SOURCE, KNX_MODULE_KEY -from .entity import KnxYamlEntity -from .schema import BinarySensorSchema +from .const import ( + ATTR_COUNTER, + ATTR_SOURCE, + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, + CONF_INVERT, + CONF_RESET_AFTER, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, + DOMAIN, + KNX_MODULE_KEY, +) +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .storage.const import CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_SENSOR, CONF_GA_STATE async def async_setup_entry( @@ -35,40 +49,38 @@ async def async_setup_entry( ) -> None: """Set up the KNX binary sensor platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.BINARY_SENSOR] - - async_add_entities( - KNXBinarySensor(knx_module, entity_config) for entity_config in config + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.BINARY_SENSOR, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiBinarySensor, + ), ) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.BINARY_SENSOR): + entities.extend( + KnxYamlBinarySensor(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get( + Platform.BINARY_SENSOR + ): + entities.extend( + KnxUiBinarySensor(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity): + +class _KnxBinarySensor(BinarySensorEntity, RestoreEntity): """Representation of a KNX binary sensor.""" _device: XknxBinarySensor - def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Initialize of KNX binary sensor.""" - super().__init__( - knx_module=knx_module, - device=XknxBinarySensor( - xknx=knx_module.xknx, - name=config[CONF_NAME], - group_address_state=config[BinarySensorSchema.CONF_STATE_ADDRESS], - invert=config[BinarySensorSchema.CONF_INVERT], - sync_state=config[BinarySensorSchema.CONF_SYNC_STATE], - ignore_internal_state=config[ - BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE - ], - context_timeout=config.get(BinarySensorSchema.CONF_CONTEXT_TIMEOUT), - reset_after=config.get(BinarySensorSchema.CONF_RESET_AFTER), - ), - ) - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._attr_force_update = self._device.ignore_internal_state - self._attr_unique_id = str(self._device.remote_value.group_address_state) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -92,3 +104,59 @@ class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity): if self._device.last_telegram is not None: attr[ATTR_SOURCE] = str(self._device.last_telegram.source_address) return attr + + +class KnxYamlBinarySensor(_KnxBinarySensor, KnxYamlEntity): + """Representation of a KNX binary sensor configured from YAML.""" + + _device: XknxBinarySensor + + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: + """Initialize of KNX binary sensor.""" + super().__init__( + knx_module=knx_module, + device=XknxBinarySensor( + xknx=knx_module.xknx, + name=config[CONF_NAME], + group_address_state=config[CONF_STATE_ADDRESS], + invert=config[CONF_INVERT], + sync_state=config[CONF_SYNC_STATE], + ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE], + context_timeout=config.get(CONF_CONTEXT_TIMEOUT), + reset_after=config.get(CONF_RESET_AFTER), + ), + ) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_force_update = self._device.ignore_internal_state + self._attr_unique_id = str(self._device.remote_value.group_address_state) + + +class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity): + """Representation of a KNX binary sensor configured from UI.""" + + _device: XknxBinarySensor + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize KNX binary sensor.""" + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + self._device = XknxBinarySensor( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + group_address_state=[ + config[DOMAIN][CONF_GA_SENSOR][CONF_GA_STATE], + *config[DOMAIN][CONF_GA_SENSOR][CONF_GA_PASSIVE], + ], + sync_state=config[DOMAIN][CONF_SYNC_STATE], + invert=config[DOMAIN].get(CONF_INVERT, False), + ignore_internal_state=config[DOMAIN].get(CONF_IGNORE_INTERNAL_STATE, False), + context_timeout=config[DOMAIN].get(CONF_CONTEXT_TIMEOUT), + reset_after=config[DOMAIN].get(CONF_RESET_AFTER), + ) + self._attr_force_update = self._device.ignore_internal_state diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 2c0153c5d2b..e3bb63581e7 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -19,6 +19,8 @@ from homeassistant.components.climate import ( FAN_LOW, FAN_MEDIUM, FAN_ON, + SWING_OFF, + SWING_ON, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -136,6 +138,14 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate: ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS ), fan_speed_mode=config[ClimateSchema.CONF_FAN_SPEED_MODE], + group_address_swing=config.get(ClimateSchema.CONF_SWING_ADDRESS), + group_address_swing_state=config.get(ClimateSchema.CONF_SWING_STATE_ADDRESS), + group_address_horizontal_swing=config.get( + ClimateSchema.CONF_SWING_HORIZONTAL_ADDRESS + ), + group_address_horizontal_swing_state=config.get( + ClimateSchema.CONF_SWING_HORIZONTAL_STATE_ADDRESS + ), group_address_humidity_state=config.get( ClimateSchema.CONF_HUMIDITY_STATE_ADDRESS ), @@ -207,6 +217,13 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): self._attr_fan_modes = [self.fan_zero_mode] + [ f"{percentage}%" for percentage in self._fan_modes_percentages[1:] ] + if self._device.swing.initialized: + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + self._attr_swing_modes = [SWING_ON, SWING_OFF] + + if self._device.horizontal_swing.initialized: + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF] self._attr_target_temperature_step = self._device.temperature_step self._attr_unique_id = ( @@ -399,6 +416,28 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): await self._device.set_fan_speed(self._fan_modes_percentages[fan_mode_index]) + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the swing setting.""" + await self._device.set_swing(swing_mode == SWING_ON) + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set the horizontal swing setting.""" + await self._device.set_horizontal_swing(swing_horizontal_mode == SWING_ON) + + @property + def swing_mode(self) -> str | None: + """Return the swing setting.""" + if self._device.swing.value is not None: + return SWING_ON if self._device.swing.value else SWING_OFF + return None + + @property + def swing_horizontal_mode(self) -> str | None: + """Return the horizontal swing setting.""" + if self._device.horizontal_swing.value is not None: + return SWING_ON if self._device.horizontal_swing.value else SWING_OFF + return None + @property def current_humidity(self) -> float | None: """Return the current humidity.""" diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 3ef35479c4e..b403018dae3 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -67,6 +67,8 @@ CONF_KNX_SECURE_USER_PASSWORD: Final = "user_password" CONF_KNX_SECURE_DEVICE_AUTHENTICATION: Final = "device_authentication" +CONF_CONTEXT_TIMEOUT: Final = "context_timeout" +CONF_IGNORE_INTERNAL_STATE: Final = "ignore_internal_state" CONF_PAYLOAD_LENGTH: Final = "payload_length" CONF_RESET_AFTER: Final = "reset_after" CONF_RESPOND_TO_READ: Final = "respond_to_read" @@ -156,7 +158,11 @@ SUPPORTED_PLATFORMS_YAML: Final = { Platform.WEATHER, } -SUPPORTED_PLATFORMS_UI: Final = {Platform.SWITCH, Platform.LIGHT} +SUPPORTED_PLATFORMS_UI: Final = { + Platform.BINARY_SENSOR, + Platform.LIGHT, + Platform.SWITCH, +} # Map KNX controller modes to HA modes. This list might not be complete. CONTROLLER_MODES: Final = { diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index caeaed6da93..b75e1a14f67 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import KNXModule from .const import ( diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 6115f8be128..33edc19fb1c 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import ( async_get_current_platform, ) from homeassistant.helpers.typing import ConfigType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import KNXModule from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 73a61be68ee..f34ce0f4589 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -10,9 +10,9 @@ "iot_class": "local_push", "loggers": ["xknx", "xknxproject"], "requirements": [ - "xknx==3.4.0", + "xknx==3.5.0", "xknxproject==3.8.1", - "knx-frontend==2025.1.18.164225" + "knx-frontend==2025.1.28.225404" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 9311046e410..c9fe0bfc34e 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -41,10 +41,12 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, Platform, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from .const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, CONF_INVERT, CONF_KNX_EXPOSE, CONF_PAYLOAD_LENGTH, @@ -211,14 +213,6 @@ class BinarySensorSchema(KNXPlatformSchema): """Voluptuous schema for KNX binary sensors.""" PLATFORM = Platform.BINARY_SENSOR - - CONF_STATE_ADDRESS = CONF_STATE_ADDRESS - CONF_SYNC_STATE = CONF_SYNC_STATE - CONF_INVERT = CONF_INVERT - CONF_IGNORE_INTERNAL_STATE = "ignore_internal_state" - CONF_CONTEXT_TIMEOUT = "context_timeout" - CONF_RESET_AFTER = CONF_RESET_AFTER - DEFAULT_NAME = "KNX Binary Sensor" ENTITY_SCHEMA = vol.All( @@ -345,6 +339,10 @@ class ClimateSchema(KNXPlatformSchema): CONF_FAN_SPEED_MODE = "fan_speed_mode" CONF_FAN_ZERO_MODE = "fan_zero_mode" CONF_HUMIDITY_STATE_ADDRESS = "humidity_state_address" + CONF_SWING_ADDRESS = "swing_address" + CONF_SWING_STATE_ADDRESS = "swing_state_address" + CONF_SWING_HORIZONTAL_ADDRESS = "swing_horizontal_address" + CONF_SWING_HORIZONTAL_STATE_ADDRESS = "swing_horizontal_state_address" DEFAULT_NAME = "KNX Climate" DEFAULT_SETPOINT_SHIFT_MODE = "DPT6010" @@ -433,6 +431,10 @@ class ClimateSchema(KNXPlatformSchema): vol.Optional(CONF_FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce( FanZeroMode ), + vol.Optional(CONF_SWING_ADDRESS): ga_list_validator, + vol.Optional(CONF_SWING_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_SWING_HORIZONTAL_ADDRESS): ga_list_validator, + vol.Optional(CONF_SWING_HORIZONTAL_STATE_ADDRESS): ga_list_validator, vol.Optional(CONF_HUMIDITY_STATE_ADDRESS): ga_list_validator, } ), diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 6c392902737..f0f760180f4 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -15,7 +15,7 @@ from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWri from homeassistant.const import CONF_TYPE, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from .const import ( diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 42b76a5a0fd..cf3f2bb9f95 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -10,6 +10,7 @@ CONF_GA_STATE: Final = "state" CONF_GA_PASSIVE: Final = "passive" CONF_DPT: Final = "dpt" +CONF_GA_SENSOR: Final = "ga_sensor" CONF_GA_SWITCH: Final = "ga_switch" CONF_GA_COLOR_TEMP: Final = "ga_color_temp" CONF_COLOR_TEMP_MIN: Final = "color_temp_min" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 84854d2ec85..d99ffa86f52 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -11,12 +11,15 @@ from homeassistant.const import ( CONF_PLATFORM, Platform, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from homeassistant.helpers.typing import VolDictType, VolSchemaType from ..const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, CONF_INVERT, + CONF_RESET_AFTER, CONF_RESPOND_TO_READ, CONF_SYNC_STATE, DOMAIN, @@ -42,6 +45,7 @@ from .const import ( CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, + CONF_GA_SENSOR, CONF_GA_STATE, CONF_GA_SWITCH, CONF_GA_WHITE_BRIGHTNESS, @@ -94,6 +98,29 @@ def optional_ga_schema(key: str, ga_selector: GASelector) -> VolDictType: } +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + vol.Required(DOMAIN): { + vol.Required(CONF_GA_SENSOR): GASelector(write=False, state_required=True), + vol.Required(CONF_RESPOND_TO_READ, default=False): bool, + vol.Required(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_INVERT): selector.BooleanSelector(), + vol.Optional(CONF_IGNORE_INTERNAL_STATE): selector.BooleanSelector(), + vol.Optional(CONF_CONTEXT_TIMEOUT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=0.1, unit_of_measurement="s" + ) + ), + vol.Optional(CONF_RESET_AFTER): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=0.1, unit_of_measurement="s" + ) + ), + }, + } +) + SWITCH_SCHEMA = vol.Schema( { vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, @@ -213,6 +240,9 @@ ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( cv.key_value_schemas( CONF_PLATFORM, { + Platform.BINARY_SENSOR: vol.Schema( + {vol.Required(CONF_DATA): BINARY_SENSOR_SCHEMA}, extra=vol.ALLOW_EXTRA + ), Platform.SWITCH: vol.Schema( {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA ), diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index dcd5f477679..df49c84b6d5 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -15,7 +15,7 @@ from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.signal_type import SignalType from .const import DOMAIN diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index 0283b65f899..6a2224c5561 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -10,7 +10,7 @@ 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 +from homeassistant.helpers import config_validation as cv def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]: diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index cdbe4e334cb..bbddbd9f348 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -50,7 +50,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.network import is_internal_request from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .browse_media import ( build_item_response, diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index c811a073cbb..8360f74ce24 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -24,8 +24,8 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kwb/sensor.py b/homeassistant/components/kwb/sensor.py index dbe57f9a517..0074c3a4344 100644 --- a/homeassistant/components/kwb/sensor.py +++ b/homeassistant/components/kwb/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index d7df7a08e76..2cdf28d5e69 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time diff --git a/homeassistant/components/lacrosse_view/const.py b/homeassistant/components/lacrosse_view/const.py index 900463cff6e..8750d1867e6 100644 --- a/homeassistant/components/lacrosse_view/const.py +++ b/homeassistant/components/lacrosse_view/const.py @@ -1,4 +1,4 @@ """Constants for the LaCrosse View integration.""" DOMAIN = "lacrosse_view" -SCAN_INTERVAL = 30 +SCAN_INTERVAL = 60 diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 2385039f53d..dddca6565e4 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 2acca879d52..406e8e40e92 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from pylamarzocco.const import BoilerType, MachineModel, PhysicalKey +from pylamarzocco.const import BoilerType, MachineModel from pylamarzocco.devices.machine import LaMarzoccoMachine from homeassistant.components.sensor import ( @@ -81,7 +81,7 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( translation_key="drink_stats_coffee", native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.statistics.drink_stats.get(PhysicalKey.A, 0), + value_fn=lambda device: device.statistics.total_coffee, available_fn=lambda device: len(device.statistics.drink_stats) > 0, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py index 779cfa10445..89659fbd2c0 100644 --- a/homeassistant/components/lametric/__init__.py +++ b/homeassistant/components/lametric/__init__.py @@ -4,8 +4,7 @@ from homeassistant.components import notify as hass_notify from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS diff --git a/homeassistant/components/lannouncer/notify.py b/homeassistant/components/lannouncer/notify.py index 6c3cd1922cf..fe486660438 100644 --- a/homeassistant/components/lannouncer/notify.py +++ b/homeassistant/components/lannouncer/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType ATTR_METHOD = "method" diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index a1be32704f7..63e0d8c8b26 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import PchkConnectionManager diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index 809701c680a..d90e264692c 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -9,7 +9,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index a6c42de0487..2694bed31d2 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -20,8 +20,7 @@ from homeassistant.core import ( SupportsResponse, ) from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import ( diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 46df71d4235..9084ec838d9 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -22,8 +22,11 @@ from homeassistant.const import ( CONF_RESOURCE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from .const import ( ADD_ENTITIES_CALLBACKS, diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 974292c6e80..2847862029f 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 22bcef4915e..2a8031b3874 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -21,8 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 16c39c25219..887bc3c3527 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -27,7 +27,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index ffffe7a4856..3d37f1c3bc5 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -24,7 +24,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import ( _ATTR_COLOR_TEMP, diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index b40cb081ed7..f6ba01dbdae 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -14,8 +14,8 @@ import voluptuous as vol from homeassistant.components.scene import Scene from homeassistant.const import CONF_PLATFORM, CONF_TIMEOUT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 65a89b7d688..d87dcf41161 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -35,7 +35,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import bind_hass -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import ( # noqa: F401 COLOR_MODES_BRIGHTNESS, diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index e496255029a..83f2ee58b5e 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import SERVICE_TURN_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR from .const import DOMAIN diff --git a/homeassistant/components/lightwave/__init__.py b/homeassistant/components/lightwave/__init__.py index 6c462b040d4..ef2a69c9f4f 100644 --- a/homeassistant/components/lightwave/__init__.py +++ b/homeassistant/components/lightwave/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 4b2b75be9d7..22e2071b6a7 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -34,7 +34,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/linksys_smart/device_tracker.py b/homeassistant/components/linksys_smart/device_tracker.py index 596b7012140..c3b0b666d50 100644 --- a/homeassistant/components/linksys_smart/device_tracker.py +++ b/homeassistant/components/linksys_smart/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DEFAULT_TIMEOUT = 10 diff --git a/homeassistant/components/linode/__init__.py b/homeassistant/components/linode/__init__.py index 80c082344e7..d59c849f8f0 100644 --- a/homeassistant/components/linode/__init__.py +++ b/homeassistant/components/linode/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/linode/binary_sensor.py b/homeassistant/components/linode/binary_sensor.py index d0c49c7171b..93bdef4a1f4 100644 --- a/homeassistant/components/linode/binary_sensor.py +++ b/homeassistant/components/linode/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/linode/switch.py b/homeassistant/components/linode/switch.py index abaf77648ef..74d2099a844 100644 --- a/homeassistant/components/linode/switch.py +++ b/homeassistant/components/linode/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( SwitchEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py index 789195e1169..fffb6357a28 100644 --- a/homeassistant/components/linux_battery/sensor.py +++ b/homeassistant/components/linux_battery/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_NAME, ATTR_SERIAL_NUMBER, CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index 9aa0b19c506..aeae8f52144 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PORT from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import CONF_DEFAULT_TRANSITION, DOMAIN diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index 2786cc8b76a..a35bf6fb65e 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -11,11 +11,11 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 76274f987cd..1f926d37a61 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -11,29 +9,16 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator -PLATFORMS_BY_TYPE = { - Robot: ( - Platform.BINARY_SENSOR, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ), - LitterRobot: (Platform.VACUUM,), - LitterRobot3: (Platform.BUTTON, Platform.TIME), - LitterRobot4: (Platform.UPDATE,), - FeederRobot: (Platform.BUTTON,), -} - - -def get_platforms_for_robots(robots: list[Robot]) -> set[Platform]: - """Get platforms for robots.""" - return { - platform - for robot in robots - for robot_type, platforms in PLATFORMS_BY_TYPE.items() - if isinstance(robot, robot_type) - for platform in platforms - } +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, + Platform.UPDATE, + Platform.VACUUM, +] async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) -> bool: @@ -41,9 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) coordinator = LitterRobotDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator - - if platforms := get_platforms_for_robots(coordinator.account.robots): - await hass.config_entries.async_forward_entry_setups(entry, platforms) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -52,9 +35,7 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" await entry.runtime_data.account.disconnect() - - platforms = get_platforms_for_robots(entry.runtime_data.account.robots) - return await hass.config_entries.async_unload_platforms(entry, platforms) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_remove_config_entry_device( diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 4f1deb9a567..f7563296711 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,6 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], + "quality_scale": "bronze", "requirements": ["pylitterbot==2024.0.0"] } diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml index 3eae5d3e668..82f01f64d18 100644 --- a/homeassistant/components/litterrobot/quality_scale.yaml +++ b/homeassistant/components/litterrobot/quality_scale.yaml @@ -1,45 +1,20 @@ rules: - # Adjust platform files for consistent flow: - # [entity description classes] - # [entity descriptions] - # [async_setup_entry] - # [entity classes]) - # Remove RequiredKeyMixins and add kw_only to classes - # Wrap multiline lambdas in parenthesis - # Extend entity description in switch.py to use value_fn instead of getattr - # Deprecate extra state attributes in vacuum.py # Bronze - action-setup: - status: todo - comment: | - Action async_set_sleep_mode is currently setup in the vacuum platform + action-setup: done appropriate-polling: status: done comment: | Primarily relies on push data, but polls every 5 minutes for missed updates brands: done - common-modules: - status: todo - comment: | - hub.py should be renamed to coordinator.py and updated accordingly - Also should not need to return bool (never used) - config-flow-test-coverage: - status: todo - comment: | - Fix stale title and docstring - Make sure every test ends in either ABORT or CREATE_ENTRY - so we also test that the flow is able to recover + common-modules: done + config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: todo - comment: Can be finished after async_set_sleep_mode is moved to async_setup + docs-actions: done docs-high-level-description: done - docs-installation-instructions: todo - docs-removal-instructions: todo - entity-event-setup: - status: todo - comment: Do we need to subscribe to both the coordinator and robot itself? + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done @@ -66,9 +41,7 @@ rules: Other fields can be moved to const.py. Consider snapshots and testing data updates # Gold - devices: - status: done - comment: Currently uses the device_info property, could be moved to _attr_device_info + devices: done diagnostics: todo discovery-update-info: status: done @@ -86,16 +59,12 @@ rules: dynamic-devices: todo entity-category: done entity-device-class: done - entity-disabled-by-default: - status: todo - comment: Check if we should disable any entities by default + entity-disabled-by-default: done entity-translations: status: todo comment: Make sure all translated states are in sentence case exception-translations: todo - icon-translations: - status: todo - comment: BRIGHTNESS_LEVEL_ICON_MAP should be migrated to icons.json + icon-translations: done reconfiguration-flow: todo repair-issues: status: done diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index 6e3743059b3..3fa93b14dd9 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -13,7 +13,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 2f9e2e9b24d..314fab6a621 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -18,7 +18,7 @@ from homeassistant.components.vacuum import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index ff2c2c4c3a3..4154f343f42 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 60eb29240cd..05aed8a827f 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -29,7 +29,7 @@ from homeassistant.const import ( # noqa: F401 ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.deprecation import ( all_with_deprecated_constants, check_if_deprecated_constant, diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index a75966414f8..a396849f049 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -16,8 +16,7 @@ from homeassistant.const import ( SERVICE_UNLOCK, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index a53a604daae..1a139bb379e 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.event_type import EventType from .const import ( diff --git a/homeassistant/components/logbook/rest_api.py b/homeassistant/components/logbook/rest_api.py index c7ba196275b..e4a8e64cecf 100644 --- a/homeassistant/components/logbook/rest_api.py +++ b/homeassistant/components/logbook/rest_api.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .helpers import async_determine_event_types from .processor import EventProcessor diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index b295b845532..e3d0d8a29fa 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -17,8 +17,8 @@ from homeassistant.components.websocket_api import ActiveConnection, messages from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import json_bytes +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from .const import DOMAIN from .helpers import ( diff --git a/homeassistant/components/logentries/__init__.py b/homeassistant/components/logentries/__init__.py index 8ddf4a1a543..68d6af7e7dd 100644 --- a/homeassistant/components/logentries/__init__.py +++ b/homeassistant/components/logentries/__init__.py @@ -8,8 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_TOKEN, EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index be6e8c1b24e..15283b246b2 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import EVENT_LOGGING_CHANGED # noqa: F401 from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import websocket_api diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index 16dbfa5b871..81133433d05 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 015f7e8ecdc..645f8f48ae2 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -14,8 +14,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index cf04cdb292a..0ce92538472 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/luftdaten/config_flow.py b/homeassistant/components/luftdaten/config_flow.py index ba14afeb092..1ee444d5c84 100644 --- a/homeassistant/components/luftdaten/config_flow.py +++ b/homeassistant/components/luftdaten/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import CONF_SENSOR_ID, DOMAIN diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 26fc5ba153e..d697d6244b5 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -17,8 +17,11 @@ from homeassistant import config_entries from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/lw12wifi/light.py b/homeassistant/components/lw12wifi/light.py index 60741c861dd..7071cc9f416 100644 --- a/homeassistant/components/lw12wifi/light.py +++ b/homeassistant/components/lw12wifi/light.py @@ -20,10 +20,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 87b5d566bb8..c5d17cfb176 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -34,8 +34,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py index 72617b2f42d..eb704a2d797 100644 --- a/homeassistant/components/mailgun/__init__.py +++ b/homeassistant/components/mailgun/__init__.py @@ -12,8 +12,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 244f38e0902..2b4d680208e 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -25,13 +25,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util DOMAIN = "manual" diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 768690e8ec5..cb03b71ce22 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -26,14 +26,14 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_point_in_time, async_track_state_change_event, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py index 89832c01937..08d78ecf5c3 100644 --- a/homeassistant/components/marytts/tts.py +++ b/homeassistant/components/marytts/tts.py @@ -11,7 +11,7 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_EFFECT, CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_VOICE = "voice" CONF_CODEC = "codec" diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index e1b488c0fce..8640aa4d074 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -39,7 +39,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event as HassEvent, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonObjectType, load_json_object diff --git a/homeassistant/components/matrix/notify.py b/homeassistant/components/matrix/notify.py index b05c7952d1f..0fc08e6c5aa 100644 --- a/homeassistant/components/matrix/notify.py +++ b/homeassistant/components/matrix/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import RoomID diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index de03d250836..7102b693e45 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Generator -from chip.clusters.Objects import ClusterAttributeDescriptor +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, NullValue from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform @@ -19,7 +19,7 @@ from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS -from .models import MatterDiscoverySchema, MatterEntityInfo +from .models import UNSET, MatterDiscoverySchema, MatterEntityInfo from .number import DISCOVERY_SCHEMAS as NUMBER_SCHEMAS from .select import DISCOVERY_SCHEMAS as SELECT_SCHEMAS from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS @@ -67,6 +67,8 @@ def async_discover_entities( if any(x in schema.required_attributes for x in discovered_attributes): continue + primary_attribute = schema.required_attributes[0] + # check vendor_id if ( schema.vendor_id is not None @@ -121,24 +123,6 @@ def async_discover_entities( ): continue - # check for required value in (primary) attribute - primary_attribute = schema.required_attributes[0] - primary_value = endpoint.get_attribute_value(None, primary_attribute) - if schema.value_contains is not None and ( - isinstance(primary_value, list) - and schema.value_contains not in primary_value - ): - continue - - # check for value that may not be present - if schema.value_is_not is not None and ( - schema.value_is_not == primary_value - or ( - isinstance(primary_value, list) and schema.value_is_not in primary_value - ) - ): - continue - # check for required value in cluster featuremap if schema.featuremap_contains is not None and ( not bool( @@ -152,6 +136,61 @@ def async_discover_entities( ): continue + # BEGIN checks on actual attribute values + # these are the least likely to be used and least efficient, so they are checked last + + # check if PRIMARY value exists but is none/null + if not schema.allow_none_value and any( + endpoint.get_attribute_value(None, val_schema) in (None, NullValue) + for val_schema in schema.required_attributes + ): + continue + + # check for required value in PRIMARY attribute + primary_value = endpoint.get_attribute_value(None, primary_attribute) + if schema.value_contains is not UNSET and ( + isinstance(primary_value, list) + and schema.value_contains not in primary_value + ): + continue + + # check for value that may not be present in PRIMARY attribute + if schema.value_is_not is not UNSET and ( + schema.value_is_not == primary_value + or ( + isinstance(primary_value, list) and schema.value_is_not in primary_value + ) + ): + continue + + # check for value that may not be present in SECONDARY attribute + secondary_attribute = ( + schema.required_attributes[1] + if len(schema.required_attributes) > 1 + else None + ) + secondary_value = ( + endpoint.get_attribute_value(None, secondary_attribute) + if secondary_attribute + else None + ) + if schema.secondary_value_is_not is not UNSET and ( + (schema.secondary_value_is_not == secondary_value) + or ( + isinstance(secondary_value, list) + and schema.secondary_value_is_not in secondary_value + ) + ): + continue + + # check for required value in SECONDARY attribute + if schema.secondary_value_contains is not UNSET and ( + isinstance(secondary_value, list) + and schema.secondary_value_contains not in secondary_value + ): + continue + + # FINISH all validation checks # all checks passed, this value belongs to an entity attributes_to_watch = list(schema.required_attributes) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index a6d0dbb08d8..96696193466 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -20,9 +20,9 @@ from propcache.api import cached_property from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import UndefinedType from .const import DOMAIN, FEATUREMAP_ATTRIBUTE_ID, ID_TYPE_DEVICE_ID diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index bd8665eb18b..f9217cabcc4 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -37,6 +37,9 @@ } }, "select": { + "laundry_washer_spin_speed": { + "default": "mdi:reload" + }, "temperature_level": { "default": "mdi:thermometer" } @@ -45,6 +48,9 @@ "contamination_state": { "default": "mdi:air-filter" }, + "current_phase": { + "default": "mdi:state-machine" + }, "air_quality": { "default": "mdi:air-filter" }, diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index f1fd7ca9fa3..4af7cc3c026 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -18,6 +18,14 @@ type SensorValueTypes = type[ ] +# A sentinel object to detect if a parameter is supplied or not. +class _UNSET_TYPE: + pass + + +UNSET = _UNSET_TYPE() + + class MatterDeviceInfo(TypedDict): """Dictionary with Matter Device info. @@ -111,16 +119,6 @@ class MatterDiscoverySchema: # are not discovered by other entities optional_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None - # [optional] the primary attribute value must contain this value - # for example for the AcceptedCommandList - # NOTE: only works for list values - value_contains: Any | None = None - - # [optional] the primary attribute value must NOT have this value - # for example to filter out invalid values (such as empty string instead of null) - # in case of a list value, the list may not contain this value - value_is_not: Any | None = None - # [optional] the primary attribute's cluster featuremap must contain this value # for example for the DoorSensor on a DoorLock Cluster featuremap_contains: int | None = None @@ -128,3 +126,25 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False + + # [optional] the primary attribute value may not be null/None + allow_none_value: bool = False + + # [optional] the primary attribute value must contain this value + # for example for the AcceptedCommandList + # NOTE: only works for list values + value_contains: Any = UNSET + + # [optional] the secondary (required) attribute value must contain this value + # for example for the AcceptedCommandList + # NOTE: only works for list values + secondary_value_contains: Any = UNSET + + # [optional] the primary attribute value must NOT have this value + # for example to filter out invalid values (such as empty string instead of null) + # in case of a list value, the list may not contain this value + value_is_not: Any = UNSET + + # [optional] the secondary (required) attribute value must NOT have this value + # for example to filter out empty lists in list sensor values + secondary_value_is_not: Any = UNSET diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 4518e83e9d0..93b6b8f75c9 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -86,6 +86,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnLevel,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, @@ -103,6 +105,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnTransitionTime,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, @@ -120,6 +124,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OffTransitionTime,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, @@ -137,6 +143,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnOffTransitionTime,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 1018bed6af0..dd4f8314bef 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -20,6 +20,17 @@ from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema +NUMBER_OF_RINSES_STATE_MAP = { + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNone: "off", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNormal: "normal", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kExtra: "extra", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kMax: "max", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kUnknownEnumValue: None, +} +NUMBER_OF_RINSES_STATE_MAP_REVERSE = { + v: k for k, v in NUMBER_OF_RINSES_STATE_MAP.items() +} + type SelectCluster = ( clusters.ModeSelect | clusters.OvenMode @@ -48,15 +59,27 @@ class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescrip """Describe Matter select entities.""" +@dataclass(frozen=True, kw_only=True) +class MatterMapSelectEntityDescription(MatterSelectEntityDescription): + """Describe Matter select entities for MatterMapSelectEntityDescription.""" + + measurement_to_ha: Callable[[int], str | None] + ha_to_native_value: Callable[[str], int | None] + + # list attribute: the attribute descriptor to get the list of values (= list of integers) + list_attribute: type[ClusterAttributeDescriptor] + + @dataclass(frozen=True, kw_only=True) class MatterListSelectEntityDescription(MatterSelectEntityDescription): """Describe Matter select entities for MatterListSelectEntity.""" - # command: a callback to create the command to send to the device - # the callback's argument will be the index of the selected list value - command: Callable[[int], ClusterCommand] # list attribute: the attribute descriptor to get the list of values (= list of strings) list_attribute: type[ClusterAttributeDescriptor] + # command: a custom callback to create the command to send to the device + # the callback's argument will be the index of the selected list value + # if omitted the command will just be a write_attribute command to the primary attribute + command: Callable[[int], ClusterCommand] | None = None class MatterAttributeSelectEntity(MatterEntity, SelectEntity): @@ -84,6 +107,29 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity): self._attr_current_option = value_convert(value) +class MatterMapSelectEntity(MatterAttributeSelectEntity): + """Representation of a Matter select entity where the options are defined in a State map.""" + + entity_description: MatterMapSelectEntityDescription + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + # the options can dynamically change based on the state of the device + available_values = cast( + list[int], + self.get_matter_attribute_value(self.entity_description.list_attribute), + ) + # map available (int) values to string representation + self._attr_options = [ + mapped_value + for value in available_values + if (mapped_value := self.entity_description.measurement_to_ha(value)) + ] + # use base implementation from MatterAttributeSelectEntity to set the current option + super()._update_from_device() + + class MatterModeSelectEntity(MatterAttributeSelectEntity): """Representation of a select entity from Matter (Mode) Cluster attribute(s).""" @@ -125,8 +171,19 @@ class MatterListSelectEntity(MatterEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" option_id = self._attr_options.index(option) - await self.send_device_command( - self.entity_description.command(option_id), + + if TYPE_CHECKING: + assert option_id is not None + + if self.entity_description.command: + # custom command defined to set the new value + await self.send_device_command( + self.entity_description.command(option_id), + ) + return + # regular write attribute to set the new value + await self.write_attribute( + value=option_id, ) @callback @@ -160,6 +217,8 @@ DISCOVERY_SCHEMAS = [ clusters.ModeSelect.Attributes.CurrentMode, clusters.ModeSelect.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -172,6 +231,8 @@ DISCOVERY_SCHEMAS = [ clusters.OvenMode.Attributes.CurrentMode, clusters.OvenMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -184,6 +245,8 @@ DISCOVERY_SCHEMAS = [ clusters.LaundryWasherMode.Attributes.CurrentMode, clusters.LaundryWasherMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -196,6 +259,8 @@ DISCOVERY_SCHEMAS = [ clusters.RefrigeratorAndTemperatureControlledCabinetMode.Attributes.CurrentMode, clusters.RefrigeratorAndTemperatureControlledCabinetMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -208,6 +273,8 @@ DISCOVERY_SCHEMAS = [ clusters.RvcCleanMode.Attributes.CurrentMode, clusters.RvcCleanMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -220,6 +287,8 @@ DISCOVERY_SCHEMAS = [ clusters.DishwasherMode.Attributes.CurrentMode, clusters.DishwasherMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -232,6 +301,8 @@ DISCOVERY_SCHEMAS = [ clusters.EnergyEvseMode.Attributes.CurrentMode, clusters.EnergyEvseMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -244,6 +315,8 @@ DISCOVERY_SCHEMAS = [ clusters.DeviceEnergyManagementMode.Attributes.CurrentMode, clusters.DeviceEnergyManagementMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -267,6 +340,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterAttributeSelectEntity, required_attributes=(clusters.OnOff.Attributes.StartUpOnOff,), + # allow None value for previous state + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -325,5 +400,39 @@ DISCOVERY_SCHEMAS = [ clusters.TemperatureControl.Attributes.SelectedTemperatureLevel, clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, ), + # don't discover this entry if the supported levels list is empty + secondary_value_is_not=[], + ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterListSelectEntityDescription( + key="LaundryWasherControlsSpinSpeed", + translation_key="laundry_washer_spin_speed", + list_attribute=clusters.LaundryWasherControls.Attributes.SpinSpeeds, + ), + entity_class=MatterListSelectEntity, + required_attributes=( + clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent, + clusters.LaundryWasherControls.Attributes.SpinSpeeds, + ), + # don't discover this entry if the spinspeeds list is empty + secondary_value_is_not=[], + ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterMapSelectEntityDescription( + key="MatterLaundryWasherNumberOfRinses", + translation_key="laundry_washer_number_of_rinses", + list_attribute=clusters.LaundryWasherControls.Attributes.SupportedRinses, + measurement_to_ha=NUMBER_OF_RINSES_STATE_MAP.get, + ha_to_native_value=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get, + ), + entity_class=MatterMapSelectEntity, + required_attributes=( + clusters.LaundryWasherControls.Attributes.NumberOfRinses, + clusters.LaundryWasherControls.Attributes.SupportedRinses, + ), + # don't discover this entry if the supported rinses list is empty + secondary_value_is_not=[], ), ] diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 77b51d2dfbb..3503e112db5 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -7,9 +7,11 @@ from datetime import datetime from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor from chip.clusters.Types import Nullable, NullValue from matter_server.client.models import device_types from matter_server.common.custom_clusters import ( + DraftElectricalMeasurementCluster, EveCluster, NeoCluster, ThirdRealityMeteringCluster, @@ -70,6 +72,9 @@ OPERATIONAL_STATE_MAP = { clusters.OperationalState.Enums.OperationalStateEnum.kRunning: "running", clusters.OperationalState.Enums.OperationalStateEnum.kPaused: "paused", clusters.OperationalState.Enums.OperationalStateEnum.kError: "error", + clusters.RvcOperationalState.Enums.OperationalStateEnum.kSeekingCharger: "seeking_charger", + clusters.RvcOperationalState.Enums.OperationalStateEnum.kCharging: "charging", + clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", } @@ -88,6 +93,26 @@ class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescrip """Describe Matter sensor entities.""" +@dataclass(frozen=True, kw_only=True) +class MatterListSensorEntityDescription(MatterSensorEntityDescription): + """Describe Matter sensor entities from MatterListSensor.""" + + # list attribute: the attribute descriptor to get the list of values (= list of strings) + list_attribute: type[ClusterAttributeDescriptor] + + +@dataclass(frozen=True, kw_only=True) +class MatterOperationalStateSensorEntityDescription(MatterSensorEntityDescription): + """Describe Matter sensor entities from Matter OperationalState objects.""" + + # list attribute: the attribute descriptor to get the list of values (= list of structs) + # needs to be set for handling OperationalState not on the OperationalState cluster, but + # on one of its derived clusters (e.g. RvcOperationalState) + state_list_attribute: type[ClusterAttributeDescriptor] = ( + clusters.OperationalState.Attributes.OperationalStateList + ) + + class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" @@ -105,9 +130,39 @@ class MatterSensor(MatterEntity, SensorEntity): self._attr_native_value = value +class MatterDraftElectricalMeasurementSensor(MatterEntity, SensorEntity): + """Representation of a Matter sensor for Matter 1.0 draft ElectricalMeasurement cluster.""" + + entity_description: MatterSensorEntityDescription + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + raw_value: Nullable | float | None + divisor: Nullable | float | None + multiplier: Nullable | float | None + + raw_value, divisor, multiplier = ( + self.get_matter_attribute_value(self._entity_info.attributes_to_watch[0]), + self.get_matter_attribute_value(self._entity_info.attributes_to_watch[1]), + self.get_matter_attribute_value(self._entity_info.attributes_to_watch[2]), + ) + + for value in (divisor, multiplier): + if value in (None, NullValue, 0): + self._attr_native_value = None + return + + if raw_value in (None, NullValue): + self._attr_native_value = None + else: + self._attr_native_value = round(raw_value / divisor * multiplier, 2) + + class MatterOperationalStateSensor(MatterSensor): """Representation of a sensor for Matter Operational State.""" + entity_description: MatterOperationalStateSensorEntityDescription states_map: dict[int, str] @callback @@ -118,10 +173,11 @@ class MatterOperationalStateSensor(MatterSensor): # therefore it is not possible to provide a fixed list of options # or to provide a mapping to a translateable string for all options operational_state_list = self.get_matter_attribute_value( - clusters.OperationalState.Attributes.OperationalStateList + self.entity_description.state_list_attribute ) if TYPE_CHECKING: operational_state_list = cast( + # cast to the generic OperationalStateStruct type just to help typing list[clusters.OperationalState.Structs.OperationalStateStruct], operational_state_list, ) @@ -141,6 +197,28 @@ class MatterOperationalStateSensor(MatterSensor): ) +class MatterListSensor(MatterSensor): + """Representation of a sensor entity from Matter list from Cluster attribute(s).""" + + entity_description: MatterListSensorEntityDescription + _attr_device_class = SensorDeviceClass.ENUM + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + self._attr_options = list_values = cast( + list[str], + self.get_matter_attribute_value(self.entity_description.list_attribute), + ) + current_value: int = self.get_matter_attribute_value( + self._entity_info.primary_attribute + ) + try: + self._attr_native_value = list_values[current_value] + except IndexError: + self._attr_native_value = None + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -641,6 +719,60 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalMeasurementActivePower", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterDraftElectricalMeasurementSensor, + required_attributes=( + DraftElectricalMeasurementCluster.Attributes.ActivePower, + DraftElectricalMeasurementCluster.Attributes.AcPowerDivisor, + DraftElectricalMeasurementCluster.Attributes.AcPowerMultiplier, + ), + absent_clusters=(clusters.ElectricalPowerMeasurement,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalMeasurementRmsVoltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterDraftElectricalMeasurementSensor, + required_attributes=( + DraftElectricalMeasurementCluster.Attributes.RmsVoltage, + DraftElectricalMeasurementCluster.Attributes.AcVoltageDivisor, + DraftElectricalMeasurementCluster.Attributes.AcVoltageMultiplier, + ), + absent_clusters=(clusters.ElectricalPowerMeasurement,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalMeasurementRmsCurrent", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterDraftElectricalMeasurementSensor, + required_attributes=( + DraftElectricalMeasurementCluster.Attributes.RmsCurrent, + DraftElectricalMeasurementCluster.Attributes.AcCurrentDivisor, + DraftElectricalMeasurementCluster.Attributes.AcCurrentMultiplier, + ), + absent_clusters=(clusters.ElectricalPowerMeasurement,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -667,7 +799,7 @@ DISCOVERY_SCHEMAS = [ ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=MatterSensorEntityDescription( + entity_description=MatterOperationalStateSensorEntityDescription( key="OperationalState", device_class=SensorDeviceClass.ENUM, translation_key="operational_state", @@ -677,6 +809,53 @@ DISCOVERY_SCHEMAS = [ clusters.OperationalState.Attributes.OperationalState, clusters.OperationalState.Attributes.OperationalStateList, ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterListSensorEntityDescription( + key="OperationalStateCurrentPhase", + translation_key="current_phase", + list_attribute=clusters.OperationalState.Attributes.PhaseList, + ), + entity_class=MatterListSensor, + required_attributes=( + clusters.OperationalState.Attributes.CurrentPhase, + clusters.OperationalState.Attributes.PhaseList, + ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterListSensorEntityDescription( + key="RvcOperationalStateCurrentPhase", + translation_key="current_phase", + list_attribute=clusters.RvcOperationalState.Attributes.PhaseList, + ), + entity_class=MatterListSensor, + required_attributes=( + clusters.RvcOperationalState.Attributes.CurrentPhase, + clusters.RvcOperationalState.Attributes.PhaseList, + ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterListSensorEntityDescription( + key="OvenCavityOperationalStateCurrentPhase", + translation_key="current_phase", + list_attribute=clusters.OvenCavityOperationalState.Attributes.PhaseList, + ), + entity_class=MatterListSensor, + required_attributes=( + clusters.OvenCavityOperationalState.Attributes.CurrentPhase, + clusters.OvenCavityOperationalState.Attributes.PhaseList, + ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -692,4 +871,37 @@ DISCOVERY_SCHEMAS = [ device_type=(device_types.Thermostat,), allow_multi=True, # also used for climate entity ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterOperationalStateSensorEntityDescription( + key="RvcOperationalState", + device_class=SensorDeviceClass.ENUM, + translation_key="operational_state", + state_list_attribute=clusters.RvcOperationalState.Attributes.OperationalStateList, + ), + entity_class=MatterOperationalStateSensor, + required_attributes=( + clusters.RvcOperationalState.Attributes.OperationalState, + clusters.RvcOperationalState.Attributes.OperationalStateList, + ), + allow_multi=True, # also used for vacuum entity + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterOperationalStateSensorEntityDescription( + key="OvenCavityOperationalState", + device_class=SensorDeviceClass.ENUM, + translation_key="operational_state", + state_list_attribute=clusters.OvenCavityOperationalState.Attributes.OperationalStateList, + ), + entity_class=MatterOperationalStateSensor, + required_attributes=( + clusters.OvenCavityOperationalState.Attributes.OperationalState, + clusters.OvenCavityOperationalState.Attributes.OperationalStateList, + ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 4054adba530..f1a123c61be 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -205,6 +205,18 @@ }, "temperature_display_mode": { "name": "Temperature display mode" + }, + "laundry_washer_number_of_rinses": { + "name": "Number of rinses", + "state": { + "off": "[%key:common::state::off%]", + "normal": "Normal", + "extra": "Extra", + "max": "Max" + } + }, + "laundry_washer_spin_speed": { + "name": "Spin speed" } }, "sensor": { @@ -246,7 +258,10 @@ "stopped": "Stopped", "running": "Running", "paused": "[%key:common::state::paused%]", - "error": "Error" + "error": "Error", + "seeking_charger": "Seeking charger", + "charging": "Charging", + "docked": "Docked" } }, "switch_current_position": { @@ -257,6 +272,9 @@ }, "battery_replacement_description": { "name": "Battery type" + }, + "current_phase": { + "name": "Current phase" } }, "switch": { diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index f31dd7b3aa3..5ee9b2e5fa0 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -261,5 +261,6 @@ DISCOVERY_SCHEMAS = [ clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateState, clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateStateProgress, ), + allow_none_value=True, ), ] diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 511b32d3182..de4a885d8fb 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -208,5 +208,6 @@ DISCOVERY_SCHEMAS = [ clusters.PowerSource.Attributes.BatPercentRemaining, ), device_type=(device_types.RoboticVacuumCleaner,), + allow_none_value=True, ), ] diff --git a/homeassistant/components/maxcube/__init__.py b/homeassistant/components/maxcube/__init__.py index d4a3a45f441..4e79a00fed0 100644 --- a/homeassistant/components/maxcube/__init__.py +++ b/homeassistant/components/maxcube/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import now diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py index 7d4f23d706e..cf8dfb5bc90 100644 --- a/homeassistant/components/mealie/coordinator.py +++ b/homeassistant/components/mealie/coordinator.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index 97b61da437a..4561c38ce80 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -29,7 +29,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py index 0eb3742a878..70995fc69b5 100644 --- a/homeassistant/components/meraki/device_tracker.py +++ b/homeassistant/components/meraki/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType CONF_VALIDATOR = "validator" diff --git a/homeassistant/components/message_bird/notify.py b/homeassistant/components/message_bird/notify.py index 6da0e8176ef..c5cbe695243 100644 --- a/homeassistant/components/message_bird/notify.py +++ b/homeassistant/components/message_bird/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_SENDER from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 62964d22bb1..e5db80b2997 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index ab2695cbd11..01917707bf7 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, P from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/met_eireann/config_flow.py b/homeassistant/components/met_eireann/config_flow.py index 422b46827da..761d0655237 100644 --- a/homeassistant/components/met_eireann/config_flow.py +++ b/homeassistant/components/met_eireann/config_flow.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN, HOME_LOCATION_NAME diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 4b79b046b75..5c4ada6b5f1 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index 3400ca52f50..95124445363 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -15,10 +15,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mfi/sensor.py b/homeassistant/components/mfi/sensor.py index b93cc669e62..f666e2d614a 100644 --- a/homeassistant/components/mfi/sensor.py +++ b/homeassistant/components/mfi/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mfi/switch.py b/homeassistant/components/mfi/switch.py index 833a2c21301..2a05018f301 100644 --- a/homeassistant/components/mfi/switch.py +++ b/homeassistant/components/mfi/switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py index aa33072089f..b5e770601b4 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -13,7 +13,7 @@ from homeassistant.components.tts import ( ) from homeassistant.const import CONF_API_KEY, CONF_REGION, CONF_TYPE, PERCENTAGE from homeassistant.generated.microsoft_tts import SUPPORTED_LANGUAGES -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_GENDER = "gender" CONF_OUTPUT = "output" diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index fa4de7f9c99..23c9885e0c5 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -16,8 +16,8 @@ from homeassistant.components import camera from homeassistant.const import ATTR_NAME, CONF_API_KEY, CONF_TIMEOUT, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/microsoft_face_detect/image_processing.py b/homeassistant/components/microsoft_face_detect/image_processing.py index 80037a29fa8..ce49f0b1f65 100644 --- a/homeassistant/components/microsoft_face_detect/image_processing.py +++ b/homeassistant/components/microsoft_face_detect/image_processing.py @@ -17,7 +17,7 @@ from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/microsoft_face_identify/image_processing.py b/homeassistant/components/microsoft_face_identify/image_processing.py index 03a6ad22fcd..025a7eccdda 100644 --- a/homeassistant/components/microsoft_face_identify/image_processing.py +++ b/homeassistant/components/microsoft_face_identify/image_processing.py @@ -16,7 +16,7 @@ from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mikrotik/device.py b/homeassistant/components/mikrotik/device.py index bf3cb47adc3..7963c48d936 100644 --- a/homeassistant/components/mikrotik/device.py +++ b/homeassistant/components/mikrotik/device.py @@ -5,8 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Any -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from .const import ATTR_DEVICE_TRACKER diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index c2d9e0d2f33..19d5c789c09 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import MikrotikConfigEntry from .coordinator import Device, MikrotikDataUpdateCoordinator diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index f937c304471..f1392ea488a 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -20,8 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType from .const import DOMAIN, KEY_LATENCY, KEY_MOTD diff --git a/homeassistant/components/minio/__init__.py b/homeassistant/components/minio/__init__.py index 57a9632a6ff..18a82f3a8ed 100644 --- a/homeassistant/components/minio/__init__.py +++ b/homeassistant/components/minio/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .minio_helper import MinioEventThread, create_minio_client diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index c0efd302c47..1980c80ce69 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ATTR_APP_DATA, diff --git a/homeassistant/components/mochad/__init__.py b/homeassistant/components/mochad/__init__.py index c8714c902a3..9e992b5babd 100644 --- a/homeassistant/components/mochad/__init__.py +++ b/homeassistant/components/mochad/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 1a331e16482..5b1b78a5aef 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -48,7 +48,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import Event, HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 319c68f50f0..81cfc3127d1 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -30,7 +30,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 262d13ad3af..750ddce8513 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -34,7 +34,7 @@ from homeassistant.core import ( State, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 1e2674a24bf..09048579859 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 28963d83d89..9b27ce9bc6c 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -7,9 +7,9 @@ import socket import motionmount from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN, EMPTY_MAC @@ -48,6 +48,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" ) + # Check we're properly authenticated or be able to become so + if not mm.is_authenticated: + if CONF_PIN not in entry.data: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="no_pin_provided", + ) + + pin = entry.data[CONF_PIN] + await mm.authenticate(pin) + if not mm.is_authenticated: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="incorrect_pin", + ) + # Store an API object for your platforms to access hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py index 50a1e334f1d..283f1f01d6e 100644 --- a/homeassistant/components/motionmount/config_flow.py +++ b/homeassistant/components/motionmount/config_flow.py @@ -1,5 +1,7 @@ """Config flow for Vogel's MotionMount.""" +import asyncio +from collections.abc import Mapping import logging import socket from typing import Any @@ -9,10 +11,11 @@ import voluptuous as vol from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, + SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT, CONF_UUID from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -34,7 +37,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Set up the instance.""" - self.discovery_info: dict[str, Any] = {} + self.connection_data: dict[str, Any] = {} + self.backoff_task: asyncio.Task | None = None + self.backoff_time: int = 0 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -43,23 +48,16 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self._show_setup_form() + self.connection_data.update(user_input) info = {} try: - info = await self._validate_input(user_input) + info = await self._validate_input_connect(self.connection_data) except (ConnectionError, socket.gaierror): return self.async_abort(reason="cannot_connect") except TimeoutError: return self.async_abort(reason="time_out") except motionmount.NotConnectedError: return self.async_abort(reason="not_connected") - except motionmount.MotionMountResponseError: - # This is most likely due to missing support for the mac address property - # Abort if the handler has config entries already - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - - # Otherwise we try to continue with the generic uid - info[CONF_UUID] = DEFAULT_DISCOVERY_UNIQUE_ID # If the device mac is valid we use it, otherwise we use the default id if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: @@ -67,17 +65,22 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): else: unique_id = DEFAULT_DISCOVERY_UNIQUE_ID - name = info.get(CONF_NAME, user_input[CONF_HOST]) + name = info.get(CONF_NAME, self.connection_data[CONF_HOST]) + self.connection_data[CONF_NAME] = name await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured( updates={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], + CONF_HOST: self.connection_data[CONF_HOST], + CONF_PORT: self.connection_data[CONF_PORT], } ) - return self.async_create_entry(title=name, data=user_input) + if not info[CONF_PIN]: + # We need a pin to authenticate + return await self.async_step_auth() + # No pin is needed + return self._create_or_update_entry() async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo @@ -91,7 +94,7 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): name = discovery_info.name.removesuffix(f".{zctype}") unique_id = discovery_info.properties.get("mac") - self.discovery_info.update( + self.connection_data.update( { CONF_HOST: host, CONF_PORT: port, @@ -114,16 +117,13 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update({"title_placeholders": {"name": name}}) try: - info = await self._validate_input(self.discovery_info) + info = await self._validate_input_connect(self.connection_data) except (ConnectionError, socket.gaierror): return self.async_abort(reason="cannot_connect") except TimeoutError: return self.async_abort(reason="time_out") except motionmount.NotConnectedError: return self.async_abort(reason="not_connected") - except motionmount.MotionMountResponseError: - info = {} - # We continue as we want to be able to connect with older FW that does not support MAC address # If the device supplied as with a valid MAC we use that if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: @@ -137,6 +137,10 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): else: await self._async_handle_discovery_without_unique_id() + if not info[CONF_PIN]: + # We need a pin to authenticate + return await self.async_step_auth() + # No pin is needed return await self.async_step_zeroconf_confirm() async def async_step_zeroconf_confirm( @@ -146,16 +150,82 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="zeroconf_confirm", - description_placeholders={CONF_NAME: self.discovery_info[CONF_NAME]}, + description_placeholders={CONF_NAME: self.connection_data[CONF_NAME]}, errors={}, ) - return self.async_create_entry( - title=self.discovery_info[CONF_NAME], - data=self.discovery_info, + return self._create_or_update_entry() + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + reauth_entry = self._get_reauth_entry() + self.connection_data.update(reauth_entry.data) + return await self.async_step_auth() + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle authentication form.""" + errors = {} + + if user_input is not None: + self.connection_data[CONF_PIN] = user_input[CONF_PIN] + + # Validate pin code + valid_or_wait_time = await self._validate_input_pin(self.connection_data) + if valid_or_wait_time is True: + return self._create_or_update_entry() + + if type(valid_or_wait_time) is int: + self.backoff_time = valid_or_wait_time + self.backoff_task = self.hass.async_create_task( + self._backoff(valid_or_wait_time) + ) + return await self.async_step_backoff() + + errors[CONF_PIN] = CONF_PIN + + return self.async_show_form( + step_id="auth", + data_schema=vol.Schema( + { + vol.Required(CONF_PIN): vol.All(int, vol.Range(min=1, max=9999)), + } + ), + errors=errors, ) - async def _validate_input(self, data: dict) -> dict[str, Any]: + async def async_step_backoff( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle backoff progress.""" + if not self.backoff_task or self.backoff_task.done(): + self.backoff_task = None + return self.async_show_progress_done(next_step_id="auth") + + return self.async_show_progress( + step_id="backoff", + description_placeholders={ + "timeout": str(self.backoff_time), + }, + progress_action="progress_action", + progress_task=self.backoff_task, + ) + + def _create_or_update_entry(self) -> ConfigFlowResult: + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + reauth_entry, data_updates=self.connection_data + ) + return self.async_create_entry( + title=self.connection_data[CONF_NAME], + data=self.connection_data, + ) + + async def _validate_input_connect(self, data: dict) -> dict[str, Any]: """Validate the user input allows us to connect.""" mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) @@ -164,7 +234,33 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): finally: await mm.disconnect() - return {CONF_UUID: format_mac(mm.mac.hex()), CONF_NAME: mm.name} + return { + CONF_UUID: format_mac(mm.mac.hex()), + CONF_NAME: mm.name, + CONF_PIN: mm.is_authenticated, + } + + async def _validate_input_pin(self, data: dict) -> bool | int: + """Validate the user input allows us to authenticate.""" + + mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) + try: + await mm.connect() + + can_authenticate = mm.can_authenticate + if can_authenticate is True: + await mm.authenticate(data[CONF_PIN]) + else: + # The backoff is running, return the remaining time + return can_authenticate + finally: + await mm.disconnect() + + can_authenticate = mm.can_authenticate + if can_authenticate is True: + return mm.is_authenticated + + return can_authenticate def _show_setup_form( self, errors: dict[str, str] | None = None @@ -180,3 +276,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): ), errors=errors or {}, ) + + async def _backoff(self, time: int) -> None: + while time > 0: + time -= 1 + self.backoff_time = time + await asyncio.sleep(1) diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index ba81c9d10bd..57a5f638d54 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -1,13 +1,12 @@ """Support for MotionMount sensors.""" import logging -import socket from typing import TYPE_CHECKING import motionmount from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_PIN from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import Entity @@ -26,6 +25,11 @@ class MotionMountEntity(Entity): def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: """Initialize general MotionMount entity.""" self.mm = mm + self.config_entry = config_entry + + # We store the pin, as we might need it during reconnect + self.pin = config_entry.data[CONF_PIN] + mac = format_mac(mm.mac.hex()) # Create a base unique id @@ -74,23 +78,3 @@ class MotionMountEntity(Entity): self.mm.remove_listener(self.async_write_ha_state) self.mm.remove_listener(self.update_name) await super().async_will_remove_from_hass() - - async def _ensure_connected(self) -> bool: - """Make sure there is a connection with the MotionMount. - - Returns false if the connection failed to be ensured. - """ - - if self.mm.is_connected: - return True - try: - await self.mm.connect() - except (ConnectionError, TimeoutError, socket.gaierror): - # We're not interested in exceptions here. In case of a failed connection - # the try/except from the caller will report it. - # The purpose of `_ensure_connected()` is only to make sure we try to - # reconnect, where failures should not be logged each time - return False - else: - _LOGGER.warning("Successfully reconnected to MotionMount") - return True diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 9b43d901a21..23fcf576af0 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -51,6 +51,38 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): self._attr_options = options + async def _ensure_connected(self) -> bool: + """Make sure there is a connection with the MotionMount. + + Returns false if the connection failed to be ensured. + """ + if self.mm.is_connected: + return True + try: + await self.mm.connect() + except (ConnectionError, TimeoutError, socket.gaierror): + # We're not interested in exceptions here. In case of a failed connection + # the try/except from the caller will report it. + # The purpose of `_ensure_connected()` is only to make sure we try to + # reconnect, where failures should not be logged each time + return False + + # Check we're properly authenticated or be able to become so + if not self.mm.is_authenticated: + if self.pin is None: + await self.mm.disconnect() + self.config_entry.async_start_reauth(self.hass) + return False + await self.mm.authenticate(self.pin) + if not self.mm.is_authenticated: + self.pin = None + await self.mm.disconnect() + self.config_entry.async_start_reauth(self.hass) + return False + + _LOGGER.debug("Successfully reconnected to MotionMount") + return True + async def async_update(self) -> None: """Get latest state from MotionMount.""" if not await self._ensure_connected(): diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index bd28156607c..bef04634431 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -1,4 +1,7 @@ { + "common": { + "incorrect_pin": "PIN is not correct" + }, "config": { "flow_title": "{name}", "step": { @@ -13,15 +16,33 @@ "zeroconf_confirm": { "description": "Do you want to set up {name}?", "title": "Discovered MotionMount" + }, + "auth": { + "title": "Authenticate to your MotionMount", + "description": "Your MotionMount requires a PIN to operate.", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + }, + "backoff": { + "title": "Authenticate to your MotionMount", + "description": "Too many incorrect PIN attempts." } }, + "error": { + "pin": "[%key:component::motionmount::common::incorrect_pin%]" + }, + "progress": { + "progress_action": "Too many incorrect PIN attempts. Please wait {timeout} s..." + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "time_out": "Failed to connect due to a time out.", + "time_out": "[%key:common::config_flow::error::timeout_connect%]", "not_connected": "Failed to connect.", - "invalid_response": "Failed to connect due to an invalid response from the MotionMount." + "invalid_response": "Failed to connect due to an invalid response from the MotionMount.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { @@ -60,6 +81,12 @@ "exceptions": { "failed_communication": { "message": "Failed to communicate with MotionMount" + }, + "no_pin_provided": { + "message": "No PIN provided" + }, + "incorrect_pin": { + "message": "[%key:component::motionmount::common::incorrect_pin%]" } } } diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index a79d933a782..db3901016f7 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -29,11 +29,10 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 613f665c302..7bdc13d0522 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -6,7 +6,7 @@ import logging import voluptuous as vol -import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components import alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index b49dc7aa24c..d736123eae8 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -26,9 +26,8 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, event as evt from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 8e5446b532e..b6056c2efd9 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -9,7 +9,7 @@ from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index e62303472ed..12619609f64 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -44,7 +44,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c7d041848f0..626e0cef64a 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -29,7 +29,7 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index bdf543e046a..d3ad57ef43d 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 21d250db29a..a14240ce008 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -21,8 +21,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_DEVICE, CONF_PLATFORM from homeassistant.core import HassJobType, HomeAssistant, callback -from homeassistant.helpers import discovery_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index f665f2c4016..5855f94dad7 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -17,7 +17,7 @@ from homeassistant.components.event import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 4d2e764a0d5..d8e96eb2734 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -27,7 +27,7 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 5d1af03ad24..bffe0ec1420 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -30,7 +30,7 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index eaaa80af223..a2f424b247d 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -42,11 +42,11 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 2d152ca12c8..43b0cbf77b3 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -49,12 +49,12 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import async_get_hass, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.json import json_dumps from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from homeassistant.util.json import json_loads_object from homeassistant.util.yaml import dump as yaml_dump diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 69bc801ff1e..901cee6f14c 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -31,11 +31,11 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, TemplateVarsType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 2113dbbd5ba..895bfba3560 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index 84442e75e73..7e0a7fd4dd8 100644 --- a/homeassistant/components/mqtt/notify.py +++ b/homeassistant/components/mqtt/notify.py @@ -9,7 +9,7 @@ from homeassistant.components.notify import NotifyEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 314bd716ee0..c6651510a36 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -11,7 +11,7 @@ from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index bacbf4d323e..ad84ebb09a3 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service_info.mqtt import ReceivePayloadType diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 1cc5ba2d2e5..5e3ca76e722 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -28,7 +28,7 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service_info.mqtt import ReceivePayloadType diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 0a54bcdb378..a305fa83485 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -20,7 +20,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 680f252fb20..9a05d1896f7 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -12,7 +12,7 @@ from homeassistant.components import tag from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_VALUE_TEMPLATE from homeassistant.core import HassJobType, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 743bfb363f3..ae6b25eff14 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -17,7 +17,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 50c5960f801..b380199332b 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 4c1d3fa8a53..967eceac326 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -35,7 +35,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py index 5e677d13cfe..20602e03f81 100644 --- a/homeassistant/components/mqtt_eventstream/__init__.py +++ b/homeassistant/components/mqtt_eventstream/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( MATCH_ALL, ) from homeassistant.core import EventOrigin, HomeAssistant, State, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt_json/device_tracker.py b/homeassistant/components/mqtt_json/device_tracker.py index 3200da56cf6..6f4e83799d1 100644 --- a/homeassistant/components/mqtt_json/device_tracker.py +++ b/homeassistant/components/mqtt_json/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_DEVICES, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 849d4562423..242c39cb983 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index 3a0953a0158..9a08fa2c73a 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -9,7 +9,7 @@ from homeassistant.components import mqtt from homeassistant.components.mqtt import valid_publish_topic from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, diff --git a/homeassistant/components/msteams/notify.py b/homeassistant/components/msteams/notify.py index a4de5d126d5..06f9bc42e91 100644 --- a/homeassistant/components/msteams/notify.py +++ b/homeassistant/components/msteams/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 052f4f556c1..e569bb93a42 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -15,9 +15,8 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index f3297bf0a6f..bcd33b7fd6c 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -16,7 +16,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_ALBUM_ARTISTS_ONLY, diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 9aa7498a2ee..4a7e20046b2 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -39,8 +39,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import ATTR_NAME, STATE_OFF from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index 9caae2ee0b4..d8c4fe1649d 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -8,7 +8,7 @@ from music_assistant_models.enums import MediaType import voluptuous as vol from homeassistant.const import ATTR_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_ACTIVE, diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index b482de8130c..d8b43517711 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mycroft/__init__.py b/homeassistant/components/mycroft/__init__.py index 557eca972e6..e5893e57a8e 100644 --- a/homeassistant/components/mycroft/__init__.py +++ b/homeassistant/components/mycroft/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "mycroft" diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index f3fb03ffac8..e616e325835 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -20,8 +20,7 @@ from homeassistant.components.mqtt import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE from homeassistant.core import callback -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index fa3464c0088..bdc83f30b21 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -22,7 +22,7 @@ from homeassistant.components.mqtt import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.unit_system import METRIC_SYSTEM diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 74dc99e76d3..c96ad6cea8e 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.decorator import Registry diff --git a/homeassistant/components/mythicbeastsdns/__init__.py b/homeassistant/components/mythicbeastsdns/__init__.py index f4de18aa0ef..58ac6051c8a 100644 --- a/homeassistant/components/mythicbeastsdns/__init__.py +++ b/homeassistant/components/mythicbeastsdns/__init__.py @@ -12,8 +12,8 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/nad/media_player.py b/homeassistant/components/nad/media_player.py index e3c22b42d28..c1efa18f72b 100644 --- a/homeassistant/components/nad/media_player.py +++ b/homeassistant/components/nad/media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/namecheapdns/__init__.py b/homeassistant/components/namecheapdns/__init__.py index 43310c5e922..7fbd49d979b 100644 --- a/homeassistant/components/namecheapdns/__init__.py +++ b/homeassistant/components/namecheapdns/__init__.py @@ -8,8 +8,8 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py index 00e5a21da18..c5a9e085b83 100644 --- a/homeassistant/components/nasweb/switch.py +++ b/homeassistant/components/nasweb/switch.py @@ -10,9 +10,9 @@ from webio_api import Output as NASwebOutput from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( BaseCoordinatorEntity, diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index ce3e7d3a002..ff3eea9252c 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 274e4c288b4..0b249db7a4b 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -15,6 +15,7 @@ import logging from typing import TYPE_CHECKING, Any from google_nest_sdm.admin_client import ( + DEFAULT_TOPIC_IAM_POLICY, AdminClient, EligibleSubscriptions, EligibleTopics, @@ -25,6 +26,11 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.util import get_random_string from . import api @@ -41,8 +47,9 @@ from .const import ( ) DATA_FLOW_IMPL = "nest_flow_implementation" +TOPIC_FORMAT = "projects/{cloud_project_id}/topics/home-assistant-{rnd}" SUBSCRIPTION_FORMAT = "projects/{cloud_project_id}/subscriptions/home-assistant-{rnd}" -SUBSCRIPTION_RAND_LENGTH = 10 +RAND_LENGTH = 10 MORE_INFO_URL = "https://www.home-assistant.io/integrations/nest/#configuration" @@ -59,6 +66,7 @@ DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/" DEVICE_ACCESS_CONSOLE_EDIT_URL = ( "https://console.nest.google.com/device-access/project/{project_id}/information" ) +CREATE_NEW_TOPIC_KEY = "create_new_topic" CREATE_NEW_SUBSCRIPTION_KEY = "create_new_subscription" _LOGGER = logging.getLogger(__name__) @@ -66,10 +74,16 @@ _LOGGER = logging.getLogger(__name__) def _generate_subscription_id(cloud_project_id: str) -> str: """Create a new subscription id.""" - rnd = get_random_string(SUBSCRIPTION_RAND_LENGTH) + rnd = get_random_string(RAND_LENGTH) return SUBSCRIPTION_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd) +def _generate_topic_id(cloud_project_id: str) -> str: + """Create a new topic id.""" + rnd = get_random_string(RAND_LENGTH) + return TOPIC_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd) + + def generate_config_title(structures: Iterable[Structure]) -> str | None: """Pick a user friendly config title based on the Google Home name(s).""" names: list[str] = [ @@ -130,7 +144,7 @@ class NestFlowHandler( if self.source == SOURCE_REAUTH: _LOGGER.debug("Skipping Pub/Sub configuration") return await self._async_finish() - return await self.async_step_pubsub() + return await self.async_step_pubsub_topic() async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -192,7 +206,9 @@ class NestFlowHandler( ) -> ConfigFlowResult: """Handle cloud project in user input.""" if user_input is not None: - self._data.update(user_input) + self._data[CONF_CLOUD_PROJECT_ID] = user_input[ + CONF_CLOUD_PROJECT_ID + ].strip() return await self.async_step_device_project() return self.async_show_form( step_id="cloud_project", @@ -213,7 +229,7 @@ class NestFlowHandler( """Collect device access project from user input.""" errors = {} if user_input is not None: - project_id = user_input[CONF_PROJECT_ID] + project_id = user_input[CONF_PROJECT_ID].strip() if project_id == self._data[CONF_CLOUD_PROJECT_ID]: _LOGGER.error( "Device Access Project ID and Cloud Project ID must not be the" @@ -240,72 +256,83 @@ class NestFlowHandler( errors=errors, ) - async def async_step_pubsub( + async def async_step_pubsub_topic( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Configure and the pre-requisites to configure Pub/Sub topics and subscriptions.""" - data = { - **self._data, - **(user_input if user_input is not None else {}), - } - cloud_project_id = data.get(CONF_CLOUD_PROJECT_ID, "").strip() - device_access_project_id = data[CONF_PROJECT_ID] - - errors: dict[str, str] = {} - if cloud_project_id: + """Configure and create Pub/Sub topic.""" + cloud_project_id = self._data[CONF_CLOUD_PROJECT_ID] + if self._admin_client is None: access_token = self._data["token"]["access_token"] self._admin_client = api.new_pubsub_admin_client( - self.hass, access_token=access_token, cloud_project_id=cloud_project_id + self.hass, + access_token=access_token, + cloud_project_id=cloud_project_id, ) - try: - eligible_topics = await self._admin_client.list_eligible_topics( - device_access_project_id=device_access_project_id - ) - except ApiException as err: - _LOGGER.error("Error listing eligible Pub/Sub topics: %s", err) - errors["base"] = "pubsub_api_error" - else: - if not eligible_topics.topic_names: - errors["base"] = "no_pubsub_topics" + errors = {} + if user_input is not None: + topic_name = user_input[CONF_TOPIC_NAME] + if topic_name == CREATE_NEW_TOPIC_KEY: + topic_name = _generate_topic_id(cloud_project_id) + _LOGGER.debug("Creating topic %s", topic_name) + try: + await self._admin_client.create_topic(topic_name) + await self._admin_client.set_topic_iam_policy( + topic_name, DEFAULT_TOPIC_IAM_POLICY + ) + except ApiException as err: + _LOGGER.error("Error creating Pub/Sub topic: %s", err) + errors["base"] = "pubsub_api_error" if not errors: - self._data[CONF_CLOUD_PROJECT_ID] = cloud_project_id - self._eligible_topics = eligible_topics - return await self.async_step_pubsub_topic() + self._data[CONF_TOPIC_NAME] = topic_name + return await self.async_step_pubsub_topic_confirm() + device_access_project_id = self._data[CONF_PROJECT_ID] + try: + eligible_topics = await self._admin_client.list_eligible_topics( + device_access_project_id=device_access_project_id + ) + except ApiException as err: + _LOGGER.error("Error listing eligible Pub/Sub topics: %s", err) + return self.async_abort(reason="pubsub_api_error") + topics = [ + *eligible_topics.topic_names, # Untranslated topic paths + CREATE_NEW_TOPIC_KEY, + ] return self.async_show_form( - step_id="pubsub", + step_id="pubsub_topic", data_schema=vol.Schema( { - vol.Required(CONF_CLOUD_PROJECT_ID, default=cloud_project_id): str, + vol.Required( + CONF_TOPIC_NAME, default=next(iter(topics)) + ): SelectSelector( + SelectSelectorConfig( + translation_key="topic_name", + mode=SelectSelectorMode.LIST, + options=topics, + ) + ) } ), description_placeholders={ - "url": CLOUD_CONSOLE_URL, "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL, "more_info_url": MORE_INFO_URL, }, errors=errors, ) - async def async_step_pubsub_topic( - self, user_input: dict[str, Any] | None = None + async def async_step_pubsub_topic_confirm( + self, user_input: dict | None = None ) -> ConfigFlowResult: - """Configure and create Pub/Sub topic.""" - if TYPE_CHECKING: - assert self._eligible_topics + """Have the user confirm the Pub/Sub topic is set correctly in Device Access Console.""" if user_input is not None: - self._data.update(user_input) return await self.async_step_pubsub_subscription() - topics = list(self._eligible_topics.topic_names) return self.async_show_form( - step_id="pubsub_topic", - data_schema=vol.Schema( - { - vol.Optional(CONF_TOPIC_NAME, default=topics[0]): vol.In(topics), - } - ), + step_id="pubsub_topic_confirm", description_placeholders={ - "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL, + "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format( + project_id=self._data[CONF_PROJECT_ID] + ), + "topic_name": self._data[CONF_TOPIC_NAME], "more_info_url": MORE_INFO_URL, }, ) @@ -362,7 +389,7 @@ class NestFlowHandler( ) return await self._async_finish() - subscriptions = {} + subscriptions = [] try: eligible_subscriptions = ( await self._admin_client.list_eligible_subscriptions( @@ -375,10 +402,8 @@ class NestFlowHandler( ) errors["base"] = "pubsub_api_error" else: - subscriptions.update( - {name: name for name in eligible_subscriptions.subscription_names} - ) - subscriptions[CREATE_NEW_SUBSCRIPTION_KEY] = "Create New" + subscriptions.extend(eligible_subscriptions.subscription_names) + subscriptions.append(CREATE_NEW_SUBSCRIPTION_KEY) return self.async_show_form( step_id="pubsub_subscription", data_schema=vol.Schema( @@ -386,7 +411,13 @@ class NestFlowHandler( vol.Optional( CONF_SUBSCRIPTION_NAME, default=next(iter(subscriptions)), - ): vol.In(subscriptions), + ): SelectSelector( + SelectSelectorConfig( + translation_key="subscription_name", + mode=SelectSelectorMode.LIST, + options=subscriptions, + ) + ) } ), description_placeholders={ diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index f7e78b2d538..cd961276082 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.1.0"] + "requirements": ["google-nest-sdm==7.1.1"] } diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index a31a2856544..23da524ab7e 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -17,7 +17,7 @@ }, "device_project": { "title": "Nest: Create a Device Access Project", - "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).", + "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Skip enabling events for now and select **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).", "data": { "project_id": "Device Access Project ID" } @@ -25,20 +25,18 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "pubsub": { - "title": "Configure Google Cloud Pub/Sub", - "description": "Home Assistant uses Cloud Pub/Sub receive realtime Nest device updates. Nest servers publish updates to a Pub/Sub topic and Home Assistant receives the updates through a Pub/Sub subscription.\n\n1. Visit the [Device Access Console]({device_access_console_url}) and ensure a Pub/Sub topic is configured.\n2. Visit the [Cloud Console]({url}) to find your Google Cloud Project ID and confirm it is correct below.\n3. The next step will attempt to auto-discover Pub/Sub topics and subscriptions.\n\nSee the integration documentation for [more info]({more_info_url}).", - "data": { - "cloud_project_id": "[%key:component::nest::config::step::cloud_project::data::cloud_project_id%]" - } - }, "pubsub_topic": { "title": "Configure Cloud Pub/Sub topic", - "description": "Nest devices publish updates on a Cloud Pub/Sub topic. Select the Pub/Sub topic below that is the same as the [Device Access Console]({device_access_console_url}). See the integration documentation for [more info]({more_info_url}).", + "description": "Nest devices publish updates on a Cloud Pub/Sub topic. You can select an existing topic if one exists, or choose to create a new topic and the next step will create it for you with the necessary permissions. See the integration documentation for [more info]({more_info_url}).", "data": { "topic_name": "Pub/Sub topic Name" } }, + "pubsub_topic_confirm": { + "title": "Enable events", + "description": "The Nest Device Access Console needs to be configured to publish device events to your Pub/Sub topic.\n\n1. Visit the [Device Access Console]({device_access_console_url}).\n2. Open the project.\n3. Enable *Events* and set the Pub/Sub topic name to `{topic_name}`\n4. Click *Add & Validate* to verify the topic is configured correctly.\n\nSee the integration documentation for [more info]({more_info_url}).", + "submit": "Confirm" + }, "pubsub_subscription": { "title": "Configure Cloud Pub/Sub subscription", "description": "Home Assistant receives realtime Nest device updates with a Cloud Pub/Sub subscription for topic `{topic}`.\n\nSelect an existing subscription below if one already exists, or the next step will create a new one for you. See the integration documentation for [more info]({more_info_url}).", @@ -70,7 +68,8 @@ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "pubsub_api_error": "[%key:component::nest::config::error::pubsub_api_error%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -109,5 +108,17 @@ } } } + }, + "selector": { + "topic_name": { + "options": { + "create_new_topic": "Create new topic" + } + }, + "subscription_name": { + "options": { + "create_new_subscription": "Create new subscription" + } + } } } diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index f33349c56ce..4346cbe8689 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py index 5c2b93bcae7..4560b7a2ecc 100644 --- a/homeassistant/components/netio/switch.py +++ b/homeassistant/components/netio/switch.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index 6c5b6f80eda..120ae9dfd7c 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -6,7 +6,7 @@ from typing import Final import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv DOMAIN: Final = "network" STORAGE_KEY: Final = "core.network" diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 5c6482da59a..7a7ceff338e 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -17,11 +17,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index becd664756b..81e7800fd01 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -32,8 +32,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index a487a3f1414..3edff53919d 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -9,7 +9,6 @@ from nextcloudmonitor import ( NextcloudMonitorRequestError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_URL, @@ -21,15 +20,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import entity_registry as er -from .coordinator import NextcloudDataUpdateCoordinator +from .coordinator import NextcloudConfigEntry, NextcloudDataUpdateCoordinator PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR, Platform.UPDATE) _LOGGER = logging.getLogger(__name__) -type NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: NextcloudConfigEntry) -> bool: """Set up the Nextcloud integration.""" diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index c9d19efbd45..10e1a000a68 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity BINARY_SENSORS: Final[list[BinarySensorEntityDescription]] = [ diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py index b5dc5e29507..d6bccec07bb 100644 --- a/homeassistant/components/nextcloud/coordinator.py +++ b/homeassistant/components/nextcloud/coordinator.py @@ -14,12 +14,16 @@ from .const import DEFAULT_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator] + class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Nextcloud data update coordinator.""" + config_entry: NextcloudConfigEntry + def __init__( - self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: ConfigEntry + self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: NextcloudConfigEntry ) -> None: """Initialize the Nextcloud coordinator.""" self.ncm = ncm @@ -28,6 +32,7 @@ class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=entry, name=self.url, update_interval=DEFAULT_SCAN_INTERVAL, ) diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index 6632b2674eb..f2ebba7fdb2 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -6,9 +6,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NextcloudConfigEntry from .const import DOMAIN -from .coordinator import NextcloudDataUpdateCoordinator +from .coordinator import NextcloudConfigEntry, NextcloudDataUpdateCoordinator class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 19ac7bb0df7..a6722821012 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utc_from_timestamp -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity UNIT_OF_LOAD: Final[str] = "load" diff --git a/homeassistant/components/nextcloud/update.py b/homeassistant/components/nextcloud/update.py index 5b9de52ad1d..aad6412b7b3 100644 --- a/homeassistant/components/nextcloud/update.py +++ b/homeassistant/components/nextcloud/update.py @@ -6,7 +6,7 @@ from homeassistant.components.update import UpdateEntity, UpdateEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index ae7a4e615d4..50674a7ed46 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -6,8 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DATA_HASS_CONFIG, DOMAIN diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index dd6b15400d9..f6d9bcde432 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -20,7 +20,7 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index 1af23ec4d9b..8f43ed8a3e8 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["nice_go"], - "requirements": ["nice-go==1.0.0"] + "requirements": ["nice-go==1.0.1"] } diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 80f47e56438..5c2b372fd25 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -18,8 +18,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index 7600a878548..31259349dea 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -34,7 +34,7 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index a1ba9ae0c61..24c016e5e64 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -14,9 +14,8 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 865ae33b38c..4f24cde0578 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -18,7 +18,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_point_in_utc_time diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index dcb4e1361fd..72bf9284573 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -21,12 +21,11 @@ from homeassistant.components.device_tracker import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_HOME_INTERVAL, diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index e05150995aa..1f436edd60c 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 9972d41ac7b..7d06baf37b6 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -7,7 +7,7 @@ from pyrail import iRail from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py index 24ef8cd4995..e45b2d9adeb 100644 --- a/homeassistant/components/nmbs/config_flow.py +++ b/homeassistant/components/nmbs/config_flow.py @@ -79,8 +79,9 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): for station in self.stations if station["id"] == user_input[CONF_STATION_TO] ] + vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS) else "" await self.async_set_unique_id( - f"{user_input[CONF_STATION_FROM]}_{user_input[CONF_STATION_TO]}" + f"{user_input[CONF_STATION_FROM]}_{user_input[CONF_STATION_TO]}{vias}" ) self._abort_if_unique_id_configured() @@ -154,12 +155,13 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_STATION_LIVE] = station_live["id"] entity_registry = er.async_get(self.hass) prefix = "live" + vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS, False) else "" if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) @@ -168,7 +170,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): DOMAIN, f"{prefix}_{station_live['name']}_{station_from['name']}_{station_to['name']}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 85ae56144a0..6d13777e10a 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -22,11 +22,11 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import 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 -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( # noqa: F401 CONF_EXCLUDE_VIAS, @@ -170,8 +170,10 @@ async def async_setup_entry( NMBSSensor( api_client, name, show_on_map, station_from, station_to, excl_vias ), - NMBSLiveBoard(api_client, station_from, station_from, station_to), - NMBSLiveBoard(api_client, station_to, station_from, station_to), + NMBSLiveBoard( + api_client, station_from, station_from, station_to, excl_vias + ), + NMBSLiveBoard(api_client, station_to, station_from, station_to, excl_vias), ] ) @@ -187,12 +189,15 @@ class NMBSLiveBoard(SensorEntity): live_station: dict[str, Any], station_from: dict[str, Any], station_to: dict[str, Any], + excl_vias: bool, ) -> None: """Initialize the sensor for getting liveboard data.""" self._station = live_station self._api_client = api_client self._station_from = station_from self._station_to = station_to + + self._excl_vias = excl_vias self._attrs: dict[str, Any] | None = {} self._state: str | None = None @@ -210,7 +215,8 @@ class NMBSLiveBoard(SensorEntity): unique_id = ( f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}" ) - return f"nmbs_live_{unique_id}" + vias = "_excl_vias" if self._excl_vias else "" + return f"nmbs_live_{unique_id}{vias}" @property def icon(self) -> str: @@ -303,7 +309,8 @@ class NMBSSensor(SensorEntity): """Return the unique ID.""" unique_id = f"{self._station_from['id']}_{self._station_to['id']}" - return f"nmbs_connection_{unique_id}" + vias = "_excl_vias" if self._excl_vias else "" + return f"nmbs_connection_{unique_id}{vias}" @property def name(self) -> str: diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py index cb02490ac08..c23177ddf94 100644 --- a/homeassistant/components/no_ip/__init__.py +++ b/homeassistant/components/no_ip/__init__.py @@ -11,11 +11,11 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index b165478927e..f6ec9dc4bf2 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME, CONF_TIME_ZONE, CONF_UNIT_SYSTEM from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_system import METRIC_SYSTEM diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 5b777205c8d..3bbf46f0264 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -7,7 +7,7 @@ from pynobo import nobo from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL, DOMAIN diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index bba4737550b..36de8c8b1ad 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -14,8 +14,8 @@ from homeassistant.components.air_quality import ( ) from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 7f41817a683..97759db4c13 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -11,11 +11,11 @@ from typing import Any, final, override from propcache.api import cached_property import voluptuous as vol -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/notify/const.py b/homeassistant/components/notify/const.py index 29064f24a66..11ce4e801a1 100644 --- a/homeassistant/components/notify/const.py +++ b/homeassistant/components/notify/const.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv ATTR_DATA = "data" diff --git a/homeassistant/components/notify_events/__init__.py b/homeassistant/components/notify_events/__init__.py index 2be97d709a9..76cfd9be4ff 100644 --- a/homeassistant/components/notify_events/__init__.py +++ b/homeassistant/components/notify_events/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index f99790664da..7ae9b3a4d9f 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CURRENCY_CENT, UnitOfVolume from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( diff --git a/homeassistant/components/numato/__init__.py b/homeassistant/components/numato/__init__.py index 00122132d44..d3882bea290 100644 --- a/homeassistant/components/numato/__init__.py +++ b/homeassistant/components/numato/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index 8882bb22a0d..6dd85e000bd 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py index a051f843226..ffaa195deaf 100644 --- a/homeassistant/components/nut/device_action.py +++ b/homeassistant/components/nut/device_action.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import NutRuntimeData diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index 04e79716423..69e2f626049 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index fef4cef48af..ddf4942ef25 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 7a9f3990435..59fd04357eb 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -28,8 +28,7 @@ from homeassistant.const import ( ) 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 import config_validation as cv, 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 diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 627ca999acd..010b45e5a1c 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.ssl import get_default_context, get_default_no_verify_context diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py index c6d7373a002..d4f8f652b80 100644 --- a/homeassistant/components/octoprint/coordinator.py +++ b/homeassistant/components/octoprint/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 5687ab36033..7f08d04e3da 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -1,11 +1,11 @@ { "config": { - "flow_title": "OctoPrint Printer: {host}", + "flow_title": "OctoPrint printer: {host}", "step": { "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "path": "Application Path", + "path": "Application path", "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", @@ -29,7 +29,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "auth_failed": "Failed to retrieve application api key", + "auth_failed": "Failed to retrieve API key", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "progress": { @@ -44,7 +44,7 @@ "services": { "printer_connect": { "name": "Connect to a printer", - "description": "Instructs the octoprint server to connect to a printer.", + "description": "Instructs the OctoPrint server to connect to a printer.", "fields": { "device_id": { "name": "Server", diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py index 4cecb9ff195..e5ccdf6ede8 100644 --- a/homeassistant/components/oem/climate.py +++ b/homeassistant/components/oem/climate.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ohmconnect/sensor.py b/homeassistant/components/ohmconnect/sensor.py index b32db33cc2d..287842178d8 100644 --- a/homeassistant/components/ohmconnect/sensor.py +++ b/homeassistant/components/ohmconnect/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index bb3716c3e74..602c53ced7b 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.2.6"] + "requirements": ["ohme==1.2.8"] } diff --git a/homeassistant/components/ombi/__init__.py b/homeassistant/components/ombi/__init__.py index d63f72592f8..c3a51bacce2 100644 --- a/homeassistant/components/ombi/__init__.py +++ b/homeassistant/components/ombi/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index 918d845993a..8e253d4bff9 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -1,7 +1,7 @@ { "domain": "onboarding", "name": "Home Assistant Onboarding", - "after_dependencies": ["hassio"], + "after_dependencies": ["backup", "hassio"], "codeowners": ["@home-assistant/core"], "dependencies": ["auth", "http", "person"], "documentation": "https://www.home-assistant.io/integrations/onboarding", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index b33440a9eb7..edf0b615779 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine +from functools import wraps from http import HTTPStatus -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Concatenate, cast from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized @@ -15,10 +16,18 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import person from homeassistant.components.auth import indieauth +from homeassistant.components.backup import ( + BackupManager, + Folder, + IncorrectPasswordError, + async_get_manager as async_get_backup_manager, + http as backup_http, +) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.system_info import async_get_system_info @@ -50,6 +59,9 @@ async def async_setup( hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) hass.http.register_view(AnalyticsOnboardingView(data, store)) + hass.http.register_view(BackupInfoView(data)) + hass.http.register_view(RestoreBackupView(data)) + hass.http.register_view(UploadBackupView(data)) class OnboardingView(HomeAssistantView): @@ -312,6 +324,119 @@ class AnalyticsOnboardingView(_BaseOnboardingView): return self.json({}) +class BackupOnboardingView(HomeAssistantView): + """Backup onboarding view.""" + + requires_auth = False + + def __init__(self, data: OnboardingStoreData) -> None: + """Initialize the view.""" + self._data = data + + +def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, BackupManager, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and inject manager.""" + + @wraps(func) + async def with_backup( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check admin and call function.""" + if self._data["done"]: + raise HTTPUnauthorized + + try: + manager = async_get_backup_manager(request.app[KEY_HASS]) + except HomeAssistantError: + return self.json( + {"error": "backup_disabled"}, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return await func(self, manager, request, *args, **kwargs) + + return with_backup + + +class BackupInfoView(BackupOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/backup/info" + name = "api:onboarding:backup:info" + + @with_backup_manager + async def get(self, manager: BackupManager, request: web.Request) -> web.Response: + """Return backup info.""" + backups, _ = await manager.async_get_backups() + return self.json( + { + "backups": [backup.as_frontend_json() for backup in backups.values()], + "state": manager.state, + "last_non_idle_event": manager.last_non_idle_event, + } + ) + + +class RestoreBackupView(BackupOnboardingView): + """Restore backup view.""" + + url = "/api/onboarding/backup/restore" + name = "api:onboarding:backup:restore" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Optional("password"): str, + vol.Optional("restore_addons"): [str], + vol.Optional("restore_database", default=True): bool, + vol.Optional("restore_folders"): [vol.Coerce(Folder)], + } + ) + ) + @with_backup_manager + async def post( + self, manager: BackupManager, request: web.Request, data: dict[str, Any] + ) -> web.Response: + """Restore a backup.""" + try: + await manager.async_restore_backup( + data["backup_id"], + agent_id=data["agent_id"], + password=data.get("password"), + restore_addons=data.get("restore_addons"), + restore_database=data["restore_database"], + restore_folders=data.get("restore_folders"), + restore_homeassistant=True, + ) + except IncorrectPasswordError: + return self.json( + {"message": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST + ) + return web.Response(status=HTTPStatus.OK) + + +class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): + """Upload backup view.""" + + url = "/api/onboarding/backup/upload" + name = "api:onboarding:backup:upload" + + @with_backup_manager + async def post(self, manager: BackupManager, request: web.Request) -> web.Response: + """Upload a backup file.""" + return await self._post(request) + + @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py new file mode 100644 index 00000000000..7419ca6e20c --- /dev/null +++ b/homeassistant/components/onedrive/__init__.py @@ -0,0 +1,167 @@ +"""The OneDrive integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider +from msgraph import GraphRequestAdapter, GraphServiceClient +from msgraph.generated.drives.item.items.items_request_builder import ( + ItemsRequestBuilder, +) +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.folder import Folder + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.instance_id import async_get as async_get_instance_id + +from .api import OneDriveConfigEntryAccessTokenProvider +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, OAUTH_SCOPES + + +@dataclass +class OneDriveRuntimeData: + """Runtime data for the OneDrive integration.""" + + items: ItemsRequestBuilder + backup_folder_id: str + + +type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Set up OneDrive from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + + session = OAuth2Session(hass, entry, implementation) + + auth_provider = BaseBearerTokenAuthenticationProvider( + access_token_provider=OneDriveConfigEntryAccessTokenProvider(session) + ) + adapter = GraphRequestAdapter( + auth_provider=auth_provider, + client=create_async_httpx_client(hass, follow_redirects=True), + ) + + graph_client = GraphServiceClient( + request_adapter=adapter, + scopes=OAUTH_SCOPES, + ) + assert entry.unique_id + drive_item = graph_client.drives.by_drive_id(entry.unique_id) + + # get approot, will be created automatically if it does not exist + try: + approot = await drive_item.special.by_drive_item_id("approot").get() + except APIError as err: + if err.response_status_code == 403: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + _LOGGER.debug("Failed to get approot", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": "approot"}, + ) from err + + if approot is None or not approot.id: + _LOGGER.debug("Failed to get approot, was None") + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": "approot"}, + ) + + instance_id = await async_get_instance_id(hass) + backup_folder_id = await _async_create_folder_if_not_exists( + items=drive_item.items, + base_folder_id=approot.id, + folder=f"backups_{instance_id[:8]}", + ) + + entry.runtime_data = OneDriveRuntimeData( + items=drive_item.items, + backup_folder_id=backup_folder_id, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Unload a OneDrive config entry.""" + _async_notify_backup_listeners_soon(hass) + return True + + +def _async_notify_backup_listeners(hass: HomeAssistant) -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + +@callback +def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: + hass.loop.call_soon(_async_notify_backup_listeners, hass) + + +async def _async_create_folder_if_not_exists( + items: ItemsRequestBuilder, + base_folder_id: str, + folder: str, +) -> str: + """Check if a folder exists and create it if it does not exist.""" + folder_item: DriveItem | None = None + + try: + folder_item = await items.by_drive_item_id(f"{base_folder_id}:/{folder}:").get() + except APIError as err: + if err.response_status_code != 404: + _LOGGER.debug("Failed to get folder %s", folder, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) from err + # is 404 not found, create folder + _LOGGER.debug("Creating folder %s", folder) + request_body = DriveItem( + name=folder, + folder=Folder(), + additional_data={ + "@microsoft_graph_conflict_behavior": "fail", + }, + ) + try: + folder_item = await items.by_drive_item_id(base_folder_id).children.post( + request_body + ) + except APIError as create_err: + _LOGGER.debug("Failed to create folder %s", folder, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_create_folder", + translation_placeholders={"folder": folder}, + ) from create_err + _LOGGER.debug("Created folder %s", folder) + else: + _LOGGER.debug("Found folder %s", folder) + if folder_item is None or not folder_item.id: + _LOGGER.debug("Failed to get folder %s, was None", folder) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) + return folder_item.id diff --git a/homeassistant/components/onedrive/api.py b/homeassistant/components/onedrive/api.py new file mode 100644 index 00000000000..934a4f74ec9 --- /dev/null +++ b/homeassistant/components/onedrive/api.py @@ -0,0 +1,53 @@ +"""API for OneDrive bound to Home Assistant OAuth.""" + +from typing import Any, cast + +from kiota_abstractions.authentication import AccessTokenProvider, AllowedHostsValidator + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow + + +class OneDriveAccessTokenProvider(AccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self) -> None: + """Initialize OneDrive auth.""" + super().__init__() + # currently allowing all hosts + self._allowed_hosts_validator = AllowedHostsValidator(allowed_hosts=[]) + + def get_allowed_hosts_validator(self) -> AllowedHostsValidator: + """Retrieve the allowed hosts validator.""" + return self._allowed_hosts_validator + + +class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self, token: str) -> None: + """Initialize OneDrive auth.""" + super().__init__() + self._token = token + + async def get_authorization_token( # pylint: disable=dangerous-default-value + self, uri: str, additional_authentication_context: dict[str, Any] = {} + ) -> str: + """Return a valid authorization token.""" + return self._token + + +class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None: + """Initialize OneDrive auth.""" + super().__init__() + self._oauth_session = oauth_session + + async def get_authorization_token( # pylint: disable=dangerous-default-value + self, uri: str, additional_authentication_context: dict[str, Any] = {} + ) -> str: + """Return a valid authorization token.""" + await self._oauth_session.async_ensure_token_valid() + return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive/application_credentials.py b/homeassistant/components/onedrive/application_credentials.py new file mode 100644 index 00000000000..b38aa9313d0 --- /dev/null +++ b/homeassistant/components/onedrive/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for the OneDrive 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 authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py new file mode 100644 index 00000000000..94d60bc6398 --- /dev/null +++ b/homeassistant/components/onedrive/backup.py @@ -0,0 +1,296 @@ +"""Support for OneDrive backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import html +import json +import logging +from typing import Any, Concatenate, cast + +from httpx import Response +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import AnonymousAuthenticationProvider +from kiota_abstractions.headers_collection import HeadersCollection +from kiota_abstractions.method import Method +from kiota_abstractions.native_response_handler import NativeResponseHandler +from kiota_abstractions.request_information import RequestInformation +from kiota_http.middleware.options import ResponseHandlerOption +from msgraph import GraphRequestAdapter +from msgraph.generated.drives.item.items.item.content.content_request_builder import ( + ContentRequestBuilder, +) +from msgraph.generated.drives.item.items.item.create_upload_session.create_upload_session_post_request_body import ( + CreateUploadSessionPostRequestBody, +) +from msgraph.generated.drives.item.items.item.drive_item_item_request_builder import ( + DriveItemItemRequestBuilder, +) +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.drive_item_uploadable_properties import ( + DriveItemUploadableProperties, +) +from msgraph_core.models import LargeFileUploadSession + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.httpx_client import get_async_client + +from . import OneDriveConfigEntry +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) +UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[OneDriveConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + return [OneDriveBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors with a specific translation key.""" + + @wraps(func) + async def wrapper( + self: OneDriveBackupAgent, *args: P.args, **kwargs: P.kwargs + ) -> _R: + try: + return await func(self, *args, **kwargs) + except APIError as err: + if err.response_status_code == 403: + self._entry.async_start_reauth(self._hass) + _LOGGER.error( + "Error during backup in %s: Status %s, message %s", + func.__name__, + err.response_status_code, + err.message, + ) + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError("Backup operation failed") from err + except TimeoutError as err: + _LOGGER.error( + "Error during backup in %s: Timeout", + func.__name__, + ) + raise BackupAgentError("Backup operation timed out") from err + + return wrapper + + +class OneDriveBackupAgent(BackupAgent): + """OneDrive backup agent.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: + """Initialize the OneDrive backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self._items = entry.runtime_data.items + self._folder_id = entry.runtime_data.backup_folder_id + self.name = entry.title + assert entry.unique_id + self.unique_id = entry.unique_id + + @handle_backup_errors + async def async_download_backup( + self, backup_id: str, **kwargs: Any + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + # this forces the query to return a raw httpx response, but breaks typing + request_config = ( + ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration( + options=[ResponseHandlerOption(NativeResponseHandler())], + ) + ) + response = cast( + Response, + await self._get_backup_file_item(backup_id).content.get( + request_configuration=request_config + ), + ) + + return response.aiter_bytes(chunk_size=1024) + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + + # upload file in chunks to support large files + upload_session_request_body = CreateUploadSessionPostRequestBody( + item=DriveItemUploadableProperties( + additional_data={ + "@microsoft.graph.conflictBehavior": "fail", + }, + ) + ) + upload_session = await self._get_backup_file_item( + backup.backup_id + ).create_upload_session.post(upload_session_request_body) + + if upload_session is None or upload_session.upload_url is None: + raise BackupAgentError( + translation_domain=DOMAIN, translation_key="backup_no_upload_session" + ) + + await self._upload_file( + upload_session.upload_url, await open_stream(), backup.size + ) + + # store metadata in description + backup_dict = backup.as_dict() + backup_dict["metadata_version"] = 1 # version of the backup metadata + description = json.dumps(backup_dict) + _LOGGER.debug("Creating metadata: %s", description) + + await self._get_backup_file_item(backup.backup_id).patch( + DriveItem(description=description) + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + + try: + await self._get_backup_file_item(backup_id).delete() + except APIError as err: + if err.response_status_code == 404: + return + raise + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups: list[AgentBackup] = [] + items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() + if items and (values := items.value): + for item in values: + if (description := item.description) is None: + continue + if "homeassistant_version" in description: + backups.append(self._backup_from_description(description)) + return backups + + @handle_backup_errors + async def async_get_backup( + self, backup_id: str, **kwargs: Any + ) -> AgentBackup | None: + """Return a backup.""" + try: + drive_item = await self._get_backup_file_item(backup_id).get() + except APIError as err: + if err.response_status_code == 404: + return None + raise + if ( + drive_item is not None + and (description := drive_item.description) is not None + ): + return self._backup_from_description(description) + return None + + def _backup_from_description(self, description: str) -> AgentBackup: + """Create a backup object from a description.""" + description = html.unescape( + description + ) # OneDrive encodes the description on save automatically + return AgentBackup.from_dict(json.loads(description)) + + def _get_backup_file_item(self, backup_id: str) -> DriveItemItemRequestBuilder: + return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}.tar:") + + async def _upload_file( + self, upload_url: str, stream: AsyncIterator[bytes], total_size: int + ) -> None: + """Use custom large file upload; SDK does not support stream.""" + + adapter = GraphRequestAdapter( + auth_provider=AnonymousAuthenticationProvider(), + client=get_async_client(self._hass), + ) + + async def async_upload( + start: int, end: int, chunk_data: bytes + ) -> LargeFileUploadSession: + info = RequestInformation() + info.url = upload_url + info.http_method = Method.PUT + info.headers = HeadersCollection() + info.headers.try_add("Content-Range", f"bytes {start}-{end}/{total_size}") + info.headers.try_add("Content-Length", str(len(chunk_data))) + info.headers.try_add("Content-Type", "application/octet-stream") + _LOGGER.debug(info.headers.get_all()) + info.set_stream_content(chunk_data) + result = await adapter.send_async(info, LargeFileUploadSession, {}) + _LOGGER.debug("Next expected range: %s", result.next_expected_ranges) + return result + + start = 0 + buffer: list[bytes] = [] + buffer_size = 0 + + async for chunk in stream: + buffer.append(chunk) + buffer_size += len(chunk) + if buffer_size >= UPLOAD_CHUNK_SIZE: + chunk_data = b"".join(buffer) + uploaded_chunks = 0 + while ( + buffer_size > UPLOAD_CHUNK_SIZE + ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 + slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE + await async_upload( + start, + start + UPLOAD_CHUNK_SIZE - 1, + chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], + ) + start += UPLOAD_CHUNK_SIZE + uploaded_chunks += 1 + buffer_size -= UPLOAD_CHUNK_SIZE + buffer = [chunk_data[UPLOAD_CHUNK_SIZE * uploaded_chunks :]] + + # upload the remaining bytes + if buffer: + _LOGGER.debug("Last chunk") + chunk_data = b"".join(buffer) + await async_upload(start, start + len(chunk_data) - 1, chunk_data) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py new file mode 100644 index 00000000000..09c0d1b44cc --- /dev/null +++ b/homeassistant/components/onedrive/config_flow.py @@ -0,0 +1,115 @@ +"""Config flow for OneDrive.""" + +from collections.abc import Mapping +import logging +from typing import Any, cast + +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider +from kiota_abstractions.method import Method +from kiota_abstractions.request_information import RequestInformation +from msgraph import GraphRequestAdapter, GraphServiceClient + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.httpx_client import get_async_client + +from .api import OneDriveConfigFlowAccessTokenProvider +from .const import DOMAIN, OAUTH_SCOPES + + +class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle OneDrive OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @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.""" + return {"scope": " ".join(OAUTH_SCOPES)} + + async def async_oauth_create_entry( + self, + data: dict[str, Any], + ) -> ConfigFlowResult: + """Handle the initial step.""" + auth_provider = BaseBearerTokenAuthenticationProvider( + access_token_provider=OneDriveConfigFlowAccessTokenProvider( + cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + ) + ) + adapter = GraphRequestAdapter( + auth_provider=auth_provider, + client=get_async_client(self.hass), + ) + + graph_client = GraphServiceClient( + request_adapter=adapter, + scopes=OAUTH_SCOPES, + ) + + # need to get adapter from client, as client changes it + request_adapter = cast(GraphRequestAdapter, graph_client.request_adapter) + + request_info = RequestInformation( + method=Method.GET, + url_template="{+baseurl}/me/drive/special/approot", + path_parameters={}, + ) + parent_span = request_adapter.start_tracing_span(request_info, "get_approot") + + # get the OneDrive id + # use low level methods, to avoid files.read permissions + # which would be required by drives.me.get() + try: + response = await request_adapter.get_http_response_message( + request_info=request_info, parent_span=parent_span + ) + except APIError: + self.logger.exception("Failed to connect to OneDrive") + return self.async_abort(reason="connection_error") + except Exception: + self.logger.exception("Unknown error") + return self.async_abort(reason="unknown") + + drive: dict = response.json() + + await self.async_set_unique_id(drive["parentReference"]["driveId"]) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + self._abort_if_unique_id_mismatch( + reason="wrong_drive", + ) + return self.async_update_reload_and_abort( + entry=reauth_entry, + data=data, + ) + + self._abort_if_unique_id_configured() + + user = drive.get("createdBy", {}).get("user", {}).get("displayName") + + title = f"{user}'s OneDrive" if user else "OneDrive" + + return self.async_create_entry(title=title, data=data) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """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/onedrive/const.py b/homeassistant/components/onedrive/const.py new file mode 100644 index 00000000000..f9d49b141e5 --- /dev/null +++ b/homeassistant/components/onedrive/const.py @@ -0,0 +1,24 @@ +"""Constants for the OneDrive integration.""" + +from collections.abc import Callable +from typing import Final + +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "onedrive" + +# replace "consumers" with "common", when adding SharePoint or OneDrive for Business support +OAUTH2_AUTHORIZE: Final = ( + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" +) +OAUTH2_TOKEN: Final = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + +OAUTH_SCOPES: Final = [ + "Files.ReadWrite.AppFolder", + "offline_access", + "openid", +] + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json new file mode 100644 index 00000000000..056e31864a4 --- /dev/null +++ b/homeassistant/components/onedrive/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "onedrive", + "name": "OneDrive", + "codeowners": ["@zweckj"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/onedrive", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["msgraph", "msgraph-core", "kiota"], + "quality_scale": "bronze", + "requirements": ["msgraph-sdk==1.16.0"] +} diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml new file mode 100644 index 00000000000..f0d58d89c9a --- /dev/null +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -0,0 +1,139 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: + status: exempt + comment: | + This integration does not have entities. + has-entity-name: + status: exempt + comment: | + This integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No Options flow. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + This integration does not have entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + This integration does not have entities. + parallel-updates: + status: exempt + comment: | + This integration does not have platforms. + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: + status: exempt + comment: | + This integration connects to a single service. + diagnostics: + status: exempt + comment: | + There is no data to diagnose. + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + docs-data-update: + status: exempt + comment: | + This integration does not poll or push. + docs-examples: + status: exempt + comment: | + This integration only serves backup. + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: | + This integration is a cloud service. + docs-supported-functions: + status: exempt + comment: | + This integration does not have entities. + docs-troubleshooting: + status: exempt + comment: | + No issues known to troubleshoot. + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + This integration connects to a single service. + entity-category: + status: exempt + comment: | + This integration does not have entities. + entity-device-class: + status: exempt + comment: | + This integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have entities. + entity-translations: + status: exempt + comment: | + This integration does not have entities. + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have entities. + reconfiguration-flow: + status: exempt + comment: | + Nothing to reconfigure. + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json new file mode 100644 index 00000000000..9cbdb2bdeae --- /dev/null +++ b/homeassistant/components/onedrive/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The OneDrive integration needs to re-authenticate your account" + } + }, + "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%]", + "connection_error": "Failed to connect to OneDrive.", + "wrong_drive": "New account does not contain previously configured OneDrive.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "failed_to_create_folder": "Failed to create backup folder" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "exceptions": { + "backup_not_found": { + "message": "Backup not found" + }, + "backup_no_content": { + "message": "Backup has no content" + }, + "backup_no_upload_session": { + "message": "Failed to start backup upload" + }, + "authentication_failed": { + "message": "Authentication failed" + }, + "failed_to_get_folder": { + "message": "Failed to get {folder} folder" + }, + "failed_to_create_folder": { + "message": "Failed to create {folder} folder" + } + } +} diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index e40e99d0903..8a5623772f7 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -147,6 +147,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="discovery_confirm", + description_placeholders={"host": self._discovery_data[CONF_HOST]}, errors=errors, ) diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index 2ab44c47892..57cdd8c483c 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -10,6 +10,7 @@ DOMAIN = "onewire" DEVICE_KEYS_0_3 = range(4) DEVICE_KEYS_0_7 = range(8) DEVICE_KEYS_A_B = ("A", "B") +DEVICE_KEYS_A_D = ("A", "B", "C", "D") DEVICE_SUPPORT = { "05": (), @@ -17,6 +18,7 @@ DEVICE_SUPPORT = { "12": (), "1D": (), "1F": (), + "20": (), "22": (), "26": (), "28": (), diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 1c4047abf0a..04141f87847 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -33,6 +33,7 @@ from homeassistant.helpers.typing import StateType from .const import ( DEVICE_KEYS_0_3, DEVICE_KEYS_A_B, + DEVICE_KEYS_A_D, OPTION_ENTRY_DEVICE_OPTIONS, OPTION_ENTRY_SENSOR_PRECISION, PRECISION_MAPPING_FAMILY_28, @@ -108,6 +109,33 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + "20": tuple( + [ + OneWireSensorEntityDescription( + key=f"latestvolt.{device_key}", + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + read_mode=READ_MODE_FLOAT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="latest_voltage_id", + translation_placeholders={"id": str(device_key)}, + ) + for device_key in DEVICE_KEYS_A_D + ] + + [ + OneWireSensorEntityDescription( + key=f"volt.{device_key}", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + read_mode=READ_MODE_FLOAT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_id", + translation_placeholders={"id": str(device_key)}, + ) + for device_key in DEVICE_KEYS_A_D + ] + ), "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), "26": ( SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION, diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9613a927f8d..46f41503d97 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -8,6 +8,9 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { + "discovery_confirm": { + "description": "Do you want to set up OWServer from {host}?" + }, "reconfigure": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -71,12 +74,18 @@ "humidity_raw": { "name": "Raw humidity" }, + "latest_voltage_id": { + "name": "Latest voltage {id}" + }, "moisture_id": { "name": "Moisture {id}" }, "thermocouple_temperature_k": { "name": "Thermocouple K temperature" }, + "voltage_id": { + "name": "Voltage {id}" + }, "voltage_vad": { "name": "VAD voltage" }, diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index cdcf88e72d7..4b9fbe7c019 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -16,7 +16,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: done comment: | @@ -45,8 +45,8 @@ rules: # Gold devices: todo diagnostics: todo - discovery: todo - discovery-update-info: todo + discovery: done + discovery-update-info: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f15f6637ab9..6d1a340fc7b 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -25,7 +25,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ABSOLUTE_MOVE, diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 1464f4224d7..2f35bea97e2 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -93,12 +93,13 @@ def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessagePar def _chat_message_convert( - message: conversation.ChatMessage[ChatCompletionMessageParam], - agent_id: str | None, + message: conversation.Content + | conversation.NativeContent[ChatCompletionMessageParam], ) -> ChatCompletionMessageParam: """Convert any native chat message for this agent to the native format.""" - if message.native is not None and message.agent_id == agent_id: - return message.native + if message.role == "native": + # mypy doesn't understand that checking role ensures content type + return message.content # type: ignore[return-value] return cast( ChatCompletionMessageParam, {"role": message.role, "content": message.content}, @@ -157,14 +158,15 @@ class OpenAIConversationEntity( async with conversation.async_get_chat_session( self.hass, user_input ) as session: - return await self._async_call_api(user_input, session) + return await self._async_handle_message(user_input, session) - async def _async_call_api( + async def _async_handle_message( self, user_input: conversation.ConversationInput, session: conversation.ChatSession[ChatCompletionMessageParam], ) -> conversation.ConversationResult: """Call the API.""" + assert user_input.agent_id options = self.entry.options try: @@ -185,8 +187,7 @@ class OpenAIConversationEntity( ] messages = [ - _chat_message_convert(message, user_input.agent_id) - for message in session.async_get_messages() + _chat_message_convert(message) for message in session.async_get_messages() ] client = self.entry.runtime_data @@ -212,11 +213,10 @@ class OpenAIConversationEntity( messages.append(_message_convert(response)) session.async_add_message( - conversation.ChatMessage( + conversation.Content( role=response.role, agent_id=user_input.agent_id, content=response.content or "", - native=messages[-1], ), ) @@ -237,11 +237,9 @@ class OpenAIConversationEntity( ) ) session.async_add_message( - conversation.ChatMessage( - role="native", + conversation.NativeContent( agent_id=user_input.agent_id, - content="", - native=messages[-1], + content=messages[-1], ) ) diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index e8a8d6859c1..2bdf9947fe2 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -26,8 +26,8 @@ from homeassistant.const import ( CONF_SOURCE, ) from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.async_ import run_callback_threadsafe diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index c228b6c1a14..de86e3d581f 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 30801a59436..4aa334da3a7 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index eb8435751c0..19d19f19a54 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -16,8 +16,8 @@ from homeassistant.components.air_quality import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 867a4781265..5e53a805753 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -23,8 +23,8 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import ( CONF_ALTITUDE, diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 80c16ee88e1..bcbf279f3f7 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -25,7 +25,7 @@ from homeassistant.const import ( PRECISION_WHOLE, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from . import DOMAIN from .const import ( diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 8d33e117287..4c66778119e 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONFIG_FLOW_VERSION, diff --git a/homeassistant/components/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py index d2ee2e2dfbd..66f35a51b87 100644 --- a/homeassistant/components/opnsense/__init__.py +++ b/homeassistant/components/opnsense/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py index da2993d1996..e804f06faa3 100644 --- a/homeassistant/components/opple/light.py +++ b/homeassistant/components/opple/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/oru/sensor.py b/homeassistant/components/oru/sensor.py index 213350db6a4..450c56ae50e 100644 --- a/homeassistant/components/oru/sensor.py +++ b/homeassistant/components/oru/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/orvibo/switch.py b/homeassistant/components/orvibo/switch.py index 2f990333cf6..211abc838e7 100644 --- a/homeassistant/components/orvibo/switch.py +++ b/homeassistant/components/orvibo/switch.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_SWITCHES, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index ff117d6577d..b3281193da3 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -20,7 +20,7 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType from .const import DOMAIN diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py index 6ddd392af7b..25380810862 100644 --- a/homeassistant/components/osramlightify/light.py +++ b/homeassistant/components/osramlightify/light.py @@ -24,10 +24,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/overkiz/climate/evo_home_controller.py b/homeassistant/components/overkiz/climate/evo_home_controller.py index 272acbb13b9..e0cb8be7380 100644 --- a/homeassistant/components/overkiz/climate/evo_home_controller.py +++ b/homeassistant/components/overkiz/climate/evo_home_controller.py @@ -11,7 +11,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import UnitOfTemperature -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..entity import OverkizDataUpdateCoordinator, OverkizEntity diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 720c3718a4f..623e5e17b66 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/panasonic_bluray/media_player.py b/homeassistant/components/panasonic_bluray/media_player.py index a7cb0780ca9..b0e23031a24 100644 --- a/homeassistant/components/panasonic_bluray/media_player.py +++ b/homeassistant/components/panasonic_bluray/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index 69800d2ef1e..6dacc08077d 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import MediaPlayerState, MediaType from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform from homeassistant.core import Context, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index 89ad6066f48..db9c35a7608 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components import frontend from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass diff --git a/homeassistant/components/peco/manifest.json b/homeassistant/components/peco/manifest.json index 698981e9361..7dc80c6f837 100644 --- a/homeassistant/components/peco/manifest.json +++ b/homeassistant/components/peco/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/peco", "iot_class": "cloud_polling", - "requirements": ["peco==0.0.30"] + "requirements": ["peco==0.1.2"] } diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index 30e5f4d2a38..1c71603e41e 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -7,21 +7,18 @@ import logging from aiopegelonline import PegelOnline from aiopegelonline.const import CONNECT_ERRORS -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION -from .coordinator import PegelOnlineDataUpdateCoordinator +from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -type PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) -> bool: """Set up PEGELONLINE entry.""" @@ -35,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) except CONNECT_ERRORS as err: raise ConfigEntryNotReady("Failed to connect") from err - coordinator = PegelOnlineDataUpdateCoordinator(hass, entry.title, api, station) + coordinator = PegelOnlineDataUpdateCoordinator(hass, entry, api, station) await coordinator.async_config_entry_first_refresh() @@ -46,6 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: PegelOnlineConfigEntry +) -> bool: """Unload PEGELONLINE entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/pegel_online/coordinator.py b/homeassistant/components/pegel_online/coordinator.py index c8233673fde..1e2471a59f2 100644 --- a/homeassistant/components/pegel_online/coordinator.py +++ b/homeassistant/components/pegel_online/coordinator.py @@ -4,6 +4,7 @@ import logging from aiopegelonline import CONNECT_ERRORS, PegelOnline, Station, StationMeasurements +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -11,12 +12,20 @@ from .const import DOMAIN, MIN_TIME_BETWEEN_UPDATES _LOGGER = logging.getLogger(__name__) +type PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator] + class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements]): """DataUpdateCoordinator for the pegel_online integration.""" + config_entry: PegelOnlineConfigEntry + def __init__( - self, hass: HomeAssistant, name: str, api: PegelOnline, station: Station + self, + hass: HomeAssistant, + config_entry: PegelOnlineConfigEntry, + api: PegelOnline, + station: Station, ) -> None: """Initialize the PegelOnlineDataUpdateCoordinator.""" self.api = api @@ -24,7 +33,8 @@ class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements super().__init__( hass, _LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.title, update_interval=MIN_TIME_BETWEEN_UPDATES, ) diff --git a/homeassistant/components/pegel_online/diagnostics.py b/homeassistant/components/pegel_online/diagnostics.py index b68437c5ee7..e3b4a166cb4 100644 --- a/homeassistant/components/pegel_online/diagnostics.py +++ b/homeassistant/components/pegel_online/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import PegelOnlineConfigEntry +from .coordinator import PegelOnlineConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index 50eb80bafa8..181c0f5dc6d 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -16,8 +16,7 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PegelOnlineConfigEntry -from .coordinator import PegelOnlineDataUpdateCoordinator +from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity diff --git a/homeassistant/components/pencom/switch.py b/homeassistant/components/pencom/switch.py index d16c7e1600c..d9d89494bd9 100644 --- a/homeassistant/components/pencom/switch.py +++ b/homeassistant/components/pencom/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py index 07ddefa9dce..e0fb55a0363 100644 --- a/homeassistant/components/permobil/config_flow.py +++ b/homeassistant/components/permobil/config_flow.py @@ -17,9 +17,8 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL from homeassistant.core import HomeAssistant, async_get_hass -from homeassistant.helpers import selector +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index a5eb8bb4f4d..2871f4b575a 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.signal_type import SignalType from homeassistant.util.uuid import random_uuid_hex diff --git a/homeassistant/components/persistent_notification/trigger.py b/homeassistant/components/persistent_notification/trigger.py index 431443d9139..8e0808f9879 100644 --- a/homeassistant/components/persistent_notification/trigger.py +++ b/homeassistant/components/persistent_notification/trigger.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index c01fc00a29e..bbc775891b7 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -8,7 +8,7 @@ from python_picnic_api import PicnicAPI import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_AMOUNT, diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 21d5603e4c2..5f1238772b0 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py index d2d83813516..fbb924d7f8f 100644 --- a/homeassistant/components/pilight/entity.py +++ b/homeassistant/components/pilight/entity.py @@ -10,7 +10,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN, EVENT, SERVICE_NAME diff --git a/homeassistant/components/pilight/light.py b/homeassistant/components/pilight/light.py index c3d1a3c234c..9e1ecbf59d4 100644 --- a/homeassistant/components/pilight/light.py +++ b/homeassistant/components/pilight/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_LIGHTS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pilight/sensor.py b/homeassistant/components/pilight/sensor.py index 5ab80f57dc6..532681e2b93 100644 --- a/homeassistant/components/pilight/sensor.py +++ b/homeassistant/components/pilight/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pilight/switch.py b/homeassistant/components/pilight/switch.py index a1976921269..9b812075e17 100644 --- a/homeassistant/components/pilight/switch.py +++ b/homeassistant/components/pilight/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_SWITCHES from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py index 02072b6cb43..385acbe4818 100644 --- a/homeassistant/components/pioneer/media_player.py +++ b/homeassistant/components/pioneer/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TIMEOUT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index 93f8ea5ad9b..1e035205f8f 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index 585b6ecfd82..6001a243a2d 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -32,7 +32,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index f398a733cd6..9adfb4a14fe 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_CLOUDHOOK, diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 48c606865df..27993a93779 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -32,7 +32,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index ae7cbb12574..3c9f35b20a4 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -36,9 +36,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import ( AUTH_CALLBACK_NAME, diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index ae60d4d7452..f7bd646f801 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], + "quality_scale": "platinum", "requirements": ["plugwise==1.6.4"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/quality_scale.yaml b/homeassistant/components/plugwise/quality_scale.yaml index a7b955b4713..55abf3c330e 100644 --- a/homeassistant/components/plugwise/quality_scale.yaml +++ b/homeassistant/components/plugwise/quality_scale.yaml @@ -15,12 +15,8 @@ rules: status: exempt comment: Plugwise integration has no custom actions common-modules: done - docs-high-level-description: - status: todo - comment: Rewrite top section, docs PR prepared waiting for 36087 merge - docs-installation-instructions: - status: todo - comment: Docs PR 36087 + docs-high-level-description: done + docs-installation-instructions: done docs-removal-instructions: done docs-actions: done brands: done @@ -35,9 +31,7 @@ rules: parallel-updates: done test-coverage: done integration-owner: done - docs-installation-parameters: - status: todo - comment: Docs PR 36087 (partial) + todo rewrite generically (PR prepared) + docs-installation-parameters: done docs-configuration-parameters: status: exempt comment: Plugwise has no options flow @@ -58,25 +52,13 @@ rules: repair-issues: status: exempt comment: This integration does not have repairs - docs-use-cases: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - docs-supported-devices: - status: todo - comment: The list is there but could be improved for readability, PR prepared waiting for 36087 merge - docs-supported-functions: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done docs-data-update: done - docs-known-limitations: - status: todo - comment: Partial in 36087 but could be more elaborate - docs-troubleshooting: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - docs-examples: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done ## Platinum async-dependency: done inject-websession: done diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py index a385565b837..08a3d0ab0b9 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DOMAIN diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 1f6af298688..bbe75ae544c 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 9b2b9736574..04dc6d76a5e 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py index be7d394993a..03f53dec390 100644 --- a/homeassistant/components/proliphix/climate.py +++ b/homeassistant/components/proliphix/climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index ab012847bba..3adc33e9935 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -63,8 +63,11 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State -from homeassistant.helpers import entityfilter, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + entityfilter, + state as state_helper, +) from homeassistant.helpers.entity_registry import ( EVENT_ENTITY_REGISTRY_UPDATED, EventEntityRegistryUpdatedData, diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index 1118e747275..e9d2bbde4e5 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -17,8 +17,8 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 763274243c5..2338464558d 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.event import ( @@ -22,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> """Set up Proximity from a config entry.""" _LOGGER.debug("setup %s with config:%s", entry.title, entry.data) - coordinator = ProximityDataUpdateCoordinator(hass, entry.title, dict(entry.data)) + coordinator = ProximityDataUpdateCoordinator(hass, entry) entry.async_on_unload( async_track_state_change_event( @@ -48,11 +47,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR]) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: ProximityConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index a8dd85c1523..055c15125f1 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -23,7 +23,6 @@ from homeassistant.core import ( ) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.location import distance @@ -75,16 +74,14 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): config_entry: ProximityConfigEntry - def __init__( - self, hass: HomeAssistant, friendly_name: str, config: ConfigType - ) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ProximityConfigEntry) -> None: """Initialize the Proximity coordinator.""" - self.ignored_zone_ids: list[str] = config[CONF_IGNORED_ZONES] - self.tracked_entities: list[str] = config[CONF_TRACKED_ENTITIES] - self.tolerance: int = config[CONF_TOLERANCE] - self.proximity_zone_id: str = config[CONF_ZONE] + self.ignored_zone_ids: list[str] = config_entry.data[CONF_IGNORED_ZONES] + self.tracked_entities: list[str] = config_entry.data[CONF_TRACKED_ENTITIES] + self.tolerance: int = config_entry.data[CONF_TOLERANCE] + self.proximity_zone_id: str = config_entry.data[CONF_ZONE] self.proximity_zone_name: str = self.proximity_zone_id.split(".")[-1] - self.unit_of_measurement: str = config.get( + self.unit_of_measurement: str = config_entry.data.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) self.entity_mapping: dict[str, list[str]] = defaultdict(list) @@ -92,7 +89,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): super().__init__( hass, _LOGGER, - name=friendly_name, + config_entry=config_entry, + name=config_entry.title, update_interval=None, ) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 6d6771debc4..0db6ea28652 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py index e5e3d01591a..f6e909f13d1 100644 --- a/homeassistant/components/proxy/camera.py +++ b/homeassistant/components/proxy/camera.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pulseaudio_loopback/switch.py b/homeassistant/components/pulseaudio_loopback/switch.py index 4ab1f905068..1974363a8e3 100644 --- a/homeassistant/components/pulseaudio_loopback/switch.py +++ b/homeassistant/components/pulseaudio_loopback/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 37ac6144d0d..603fe89d542 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -24,7 +24,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py index b5c517c8662..faca654b420 100644 --- a/homeassistant/components/pushsafer/notify.py +++ b/homeassistant/components/pushsafer/notify.py @@ -21,7 +21,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 6327164e3c8..4d120e9fae7 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import ATTR_POWER, ATTR_POWER_P3, DOMAIN from .coordinator import ElecPricesDataUpdateCoordinator diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 5df11711d6f..b9bfc579cfc 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -22,8 +22,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index dbd1a5dce4b..0729d73a034 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -36,8 +36,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util import raise_if_invalid_filename -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, raise_if_invalid_filename from homeassistant.util.yaml.loader import load_yaml_dict _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json index ac76110363f..b7d277f3953 100644 --- a/homeassistant/components/qbus/manifest.json +++ b/homeassistant/components/qbus/manifest.json @@ -13,5 +13,5 @@ "cloudapp/QBUSMQTTGW/+/state" ], "quality_scale": "bronze", - "requirements": ["qbusmqttapi==1.2.3"] + "requirements": ["qbusmqttapi==1.2.4"] } diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index c1266ab951b..c235d441133 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py index dc68472d94e..6491dca2e2c 100644 --- a/homeassistant/components/quantum_gateway/device_tracker.py +++ b/homeassistant/components/quantum_gateway/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/qvr_pro/__init__.py b/homeassistant/components/qvr_pro/__init__.py index 9aad94790c6..98f0bcbaf99 100644 --- a/homeassistant/components/qvr_pro/__init__.py +++ b/homeassistant/components/qvr_pro/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/qwikswitch/__init__.py b/homeassistant/components/qwikswitch/__init__.py index 776e32dded1..d3cf2ff3d9b 100644 --- a/homeassistant/components/qwikswitch/__init__.py +++ b/homeassistant/components/qwikswitch/__init__.py @@ -18,8 +18,8 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py index f1eef40f307..0ee12612323 100644 --- a/homeassistant/components/raincloud/__init__.py +++ b/homeassistant/components/raincloud/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/raincloud/binary_sensor.py b/homeassistant/components/raincloud/binary_sensor.py index 2696c192ed6..84621aba99d 100644 --- a/homeassistant/components/raincloud/binary_sensor.py +++ b/homeassistant/components/raincloud/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py index 1f9d8d7b2c5..8aaec605c04 100644 --- a/homeassistant/components/raincloud/sensor.py +++ b/homeassistant/components/raincloud/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/raincloud/switch.py b/homeassistant/components/raincloud/switch.py index 59a11a6b167..babadcba676 100644 --- a/homeassistant/components/raincloud/switch.py +++ b/homeassistant/components/raincloud/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index ae9a5886d59..fadc966bc3d 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py index 35b7757580e..406100388e6 100644 --- a/homeassistant/components/random/config_flow.py +++ b/homeassistant/components/random/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index aad4fcb851c..590b391c3a0 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/raspyrfm/switch.py b/homeassistant/components/raspyrfm/switch.py index 37835ecb40a..b9506c3688c 100644 --- a/homeassistant/components/raspyrfm/switch.py +++ b/homeassistant/components/raspyrfm/switch.py @@ -25,7 +25,7 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index a40760c67f4..5a95ace92cb 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, INCLUDE_EXCLUDE_FILTER_SCHEMA_INNER, diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index fc8b136f38a..05a5731e791 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -45,7 +45,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from homeassistant.util.event_type import EventType diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index cefce9c4e72..d1a2405406e 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -47,7 +47,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index dc49ebb9768..4323ad9466b 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -20,7 +20,7 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..db_schema import StateAttributes, States from ..filters import Filters diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 2d8f4da5f38..aed2fcf8508 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -25,7 +25,7 @@ from sqlalchemy.orm.session import Session from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..const import LAST_REPORTED_SCHEMA_VERSION from ..db_schema import ( diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py index b5e67ff050b..11ea9141fc0 100644 --- a/homeassistant/components/recorder/models/legacy.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -14,7 +14,7 @@ from homeassistant.const import ( COMPRESSED_STATE_STATE, ) from homeassistant.core import Context, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .state_attributes import decode_attributes_from_source from .time import process_timestamp diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 1ceaee633ae..919ee078a99 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -16,7 +16,7 @@ from homeassistant.const import ( COMPRESSED_STATE_STATE, ) from homeassistant.core import Context, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .state_attributes import decode_attributes_from_source diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py index 33218000faa..91acad1500e 100644 --- a/homeassistant/components/recorder/models/time.py +++ b/homeassistant/components/recorder/models/time.py @@ -6,7 +6,7 @@ from datetime import datetime import logging from typing import overload -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index 2be02fe8091..cc74d7a2376 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -9,13 +9,13 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.service import ( async_extract_entity_ids, async_register_admin_service, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN from .core import Recorder diff --git a/homeassistant/components/recorder/table_managers/recorder_runs.py b/homeassistant/components/recorder/table_managers/recorder_runs.py index 4ca0aa18b88..191fa44c194 100644 --- a/homeassistant/components/recorder/table_managers/recorder_runs.py +++ b/homeassistant/components/recorder/table_managers/recorder_runs.py @@ -6,7 +6,7 @@ from datetime import datetime from sqlalchemy.orm.session import Session -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..db_schema import RecorderRuns diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a1f8d90953c..a686c7c6498 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -34,7 +34,7 @@ from homeassistant.helpers.recorder import ( # noqa: F401 get_instance, session_scope, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DEFAULT_MAX_BIND_VARS, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect from .db_schema import ( diff --git a/homeassistant/components/recswitch/switch.py b/homeassistant/components/recswitch/switch.py index 78fc0a805f6..f5b566ce59d 100644 --- a/homeassistant/components/recswitch/switch.py +++ b/homeassistant/components/recswitch/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py index 35962ac091b..564cc6c3c06 100644 --- a/homeassistant/components/reddit/sensor.py +++ b/homeassistant/components/reddit/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 40b27014211..1d9b281e9b7 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -20,10 +20,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index d544c42efe1..0d1c54efb56 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import configurator from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index b3a8075c6ba..42e8517c1e8 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index bf31e4bb55a..91b389c5a1e 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 56b3655ef94..00edd4547cb 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -18,8 +18,7 @@ import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.util.percentage import ( diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index 27ddc62a847..16c92d6cd37 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index c976506d1ba..fa5bd388009 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py index 1ca3c55e2b2..ace216e1918 100644 --- a/homeassistant/components/rest/notify.py +++ b/homeassistant/components/rest/notify.py @@ -31,7 +31,7 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index f7fd8a36113..62ed2d5c5b2 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -26,7 +26,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_ENTITY_BASE_SCHEMA, diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index fc6ce8c6749..b95e6dd72b7 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index ee93fde35fa..fe3702510af 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -30,8 +30,8 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 7e86854dbce..85195fb1581 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py index 29046ba7616..43a7c03c67b 100644 --- a/homeassistant/components/rflink/binary_sensor.py +++ b/homeassistant/components/rflink/binary_sensor.py @@ -20,9 +20,8 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, event as evt from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/const.py b/homeassistant/components/rflink/const.py index cc52ea978bd..83eb2915f70 100644 --- a/homeassistant/components/rflink/const.py +++ b/homeassistant/components/rflink/const.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_ALIASES = "aliases" CONF_GROUP_ALIASES = "group_aliases" diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index 695825cf31b..8b21bc9274d 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 00117140abb..2a5b1ccf8d7 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 89632ac50b3..027c39da70f 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -35,7 +35,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py index 23b93896878..bbbce2b8e9a 100644 --- a/homeassistant/components/rflink/switch.py +++ b/homeassistant/components/rflink/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rfxtrx/device_action.py b/homeassistant/components/rfxtrx/device_action.py index 405daa37ec5..c3f61dee026 100644 --- a/homeassistant/components/rfxtrx/device_action.py +++ b/homeassistant/components/rfxtrx/device_action.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DATA_RFXOBJECT, DOMAIN diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index a23fd8f73de..7d5654947d8 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -23,8 +23,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import get_auth_user_agent diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 9ae0bac1004..62c5217a89b 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -10,7 +10,7 @@ from ring_doorbell import RingStickUpCam from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import RingConfigEntry from .coordinator import RingDataCoordinator diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 1f146bcf358..8320a3ec47f 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -18,7 +18,7 @@ "2fa": "Two-factor code" }, "data_description": { - "2fa": "Account verification code via the method selected in your ring account settings." + "2fa": "Account verification code via the method selected in your Ring account settings." } }, "reauth_confirm": { @@ -32,7 +32,7 @@ } }, "reconfigure": { - "title": "Reconfigure Ring Integration", + "title": "Reconfigure Ring integration", "description": "Will create a new Authorized Device for {username} at ring.com", "data": { "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index e81d483adf3..cab5654fc5a 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import RingConfigEntry from .coordinator import RingDataCoordinator diff --git a/homeassistant/components/ripple/sensor.py b/homeassistant/components/ripple/sensor.py index 72510ea251d..30d2d77dcb4 100644 --- a/homeassistant/components/ripple/sensor.py +++ b/homeassistant/components/ripple/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index ac6c66bb6d2..c3217d9334e 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME, CONF_TIMEOUT, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 9ab9226c9a5..1b34dc891d1 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -28,6 +28,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 +from .roborock_storage import async_remove_map_storage SCAN_INTERVAL = timedelta(seconds=30) @@ -259,3 +260,8 @@ async def update_listener(hass: HomeAssistant, entry: RoborockConfigEntry) -> No """Handle options update.""" # Reload entry to update data await hass.config_entries.async_reload(entry.entry_id) + + +async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: + """Handle removal of an entry.""" + await async_remove_map_storage(hass, entry.entry_id) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 4a9bd14bfe1..cc8d34fbadc 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -49,5 +49,7 @@ IMAGE_CACHE_INTERVAL = 90 MAP_SLEEP = 3 GET_MAPS_SERVICE_NAME = "get_maps" +MAP_FILE_FORMAT = "PNG" +MAP_FILENAME_SUFFIX = ".png" SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position" diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index d34ba49da52..36333f1c55e 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -16,6 +16,7 @@ from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClient from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -26,6 +27,7 @@ from homeassistant.util import slugify from .const import DOMAIN from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo +from .roborock_storage import RoborockMapStorage SCAN_INTERVAL = timedelta(seconds=30) @@ -35,6 +37,8 @@ _LOGGER = logging.getLogger(__name__) class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Class to manage fetching data from the API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -72,6 +76,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Maps from map flag to map name self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} + self.map_storage = RoborockMapStorage( + hass, self.config_entry.entry_id, slugify(self.duid) + ) async def _async_setup(self) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 8717920b907..b0de4f9caa5 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -1,27 +1,33 @@ """Support for Roborock image.""" import asyncio +from collections.abc import Callable from datetime import datetime import io -from itertools import chain from roborock import RoborockCommand from vacuum_map_parser_base.config.color import ColorsPalette -from vacuum_map_parser_base.config.drawable import Drawable from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.config.size import Sizes from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import RoborockConfigEntry -from .const import DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_SLEEP +from .const import ( + DEFAULT_DRAWABLES, + DOMAIN, + DRAWABLES, + IMAGE_CACHE_INTERVAL, + MAP_FILE_FORMAT, + MAP_SLEEP, +) from .coordinator import RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -38,17 +44,35 @@ async def async_setup_entry( for drawable, default_value in DEFAULT_DRAWABLES.items() if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) ] - entities = list( - chain.from_iterable( - await asyncio.gather( - *( - create_coordinator_maps(coord, drawables) - for coord in config_entry.runtime_data.v1 - ) - ) - ) + parser = RoborockMapDataParser( + ColorsPalette(), Sizes(), drawables, ImageConfig(), [] + ) + + def parse_image(map_bytes: bytes) -> bytes | None: + parsed_map = parser.parse(map_bytes) + if parsed_map.image is None: + return None + img_byte_arr = io.BytesIO() + parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) + return img_byte_arr.getvalue() + + await asyncio.gather( + *(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1) + ) + async_add_entities( + ( + RoborockMap( + config_entry, + f"{coord.duid_slug}_map_{map_info.name}", + coord, + map_info.flag, + map_info.name, + parse_image, + ) + for coord in config_entry.runtime_data.v1 + for map_info in coord.maps.values() + ), ) - async_add_entities(entities) class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): @@ -56,39 +80,27 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): _attr_has_entity_name = True image_last_updated: datetime + _attr_name: str def __init__( self, + config_entry: ConfigEntry, unique_id: str, coordinator: RoborockDataUpdateCoordinator, map_flag: int, - starting_map: bytes, map_name: str, - drawables: list[Drawable], + parser: Callable[[bytes], bytes | None], ) -> None: """Initialize a Roborock map.""" RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator) ImageEntity.__init__(self, coordinator.hass) + self.config_entry = config_entry self._attr_name = map_name - self.parser = RoborockMapDataParser( - ColorsPalette(), Sizes(), drawables, ImageConfig(), [] - ) - self._attr_image_last_updated = dt_util.utcnow() + self.parser = parser self.map_flag = map_flag - try: - self.cached_map = self._create_image(starting_map) - except HomeAssistantError: - # If we failed to update the image on init, - # we set cached_map to empty bytes - # so that we are unavailable and can try again later. - self.cached_map = b"" + self.cached_map = b"" self._attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def available(self) -> bool: - """Determines if the entity is available.""" - return self.cached_map != b"" - @property def is_selected(self) -> bool: """Return if this map is the currently selected map.""" @@ -107,6 +119,14 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) ) + async def async_added_to_hass(self) -> None: + """When entity is added to hass load any previously cached maps from disk.""" + await super().async_added_to_hass() + content = await self.coordinator.map_storage.async_load_map(self.map_flag) + self.cached_map = content or b"" + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() + def _handle_coordinator_update(self) -> None: # Bump last updated every third time the coordinator runs, so that async_image # will be called and we will evaluate on the new coordinator data if we should @@ -127,47 +147,40 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): ), return_exceptions=True, ) - if not isinstance(response[0], bytes): + if ( + not isinstance(response[0], bytes) + or (content := self.parser(response[0])) is None + ): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="map_failure", ) - map_data = response[0] - self.cached_map = self._create_image(map_data) + if self.cached_map != content: + self.cached_map = content + self.config_entry.async_create_task( + self.hass, + self.coordinator.map_storage.async_save_map( + self.map_flag, + content, + ), + f"{self.unique_id} map", + ) return self.cached_map - def _create_image(self, map_bytes: bytes) -> bytes: - """Create an image using the map parser.""" - parsed_map = self.parser.parse(map_bytes) - if parsed_map.image is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="map_failure", - ) - img_byte_arr = io.BytesIO() - parsed_map.image.data.save(img_byte_arr, format="PNG") - return img_byte_arr.getvalue() - -async def create_coordinator_maps( - coord: RoborockDataUpdateCoordinator, drawables: list[Drawable] -) -> list[RoborockMap]: +async def refresh_coordinators( + hass: HomeAssistant, coord: RoborockDataUpdateCoordinator +) -> None: """Get the starting map information for all maps for this device. The following steps must be done synchronously. Only one map can be loaded at a time per device. """ - entities = [] 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_info in maps_info: - # Load the map - so we can access it with get_map_v1 + map_flags = sorted(coord.maps, key=lambda data: data == cur_map, reverse=True) + for map_flag in map_flags: 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]) @@ -175,28 +188,11 @@ async def create_coordinator_maps( # We cannot get the map until the roborock servers fully process the # map change. await asyncio.sleep(MAP_SLEEP) - # Get the map data - map_update = await asyncio.gather( - *[coord.cloud_api.get_map_v1(), coord.set_current_map_rooms()], - return_exceptions=True, - ) - # If we fail to get the map, we should set it to empty byte, - # still create it, and set it as unavailable. - api_data: bytes = map_update[0] if isinstance(map_update[0], bytes) else b"" - entities.append( - RoborockMap( - f"{slugify(coord.duid)}_map_{map_info.name}", - coord, - map_flag, - api_data, - map_info.name, - drawables, - ) - ) + await coord.set_current_map_rooms() + 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]) coord.current_map = cur_map - return entities diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index d104ebff12a..76d7ab98a34 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==2.8.4", + "python-roborock==2.9.7", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/homeassistant/components/roborock/roborock_storage.py b/homeassistant/components/roborock/roborock_storage.py new file mode 100644 index 00000000000..62e15e889be --- /dev/null +++ b/homeassistant/components/roborock/roborock_storage.py @@ -0,0 +1,81 @@ +"""Roborock storage.""" + +import logging +from pathlib import Path +import shutil + +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, MAP_FILENAME_SUFFIX + +_LOGGER = logging.getLogger(__name__) + +STORAGE_PATH = f".storage/{DOMAIN}" +MAPS_PATH = "maps" + + +def _storage_path_prefix(hass: HomeAssistant, entry_id: str) -> Path: + return Path(hass.config.path(STORAGE_PATH)) / entry_id + + +class RoborockMapStorage: + """Store and retrieve maps for a Roborock device. + + An instance of RoborockMapStorage is created for each device and manages + local storage of maps for that device. + """ + + def __init__(self, hass: HomeAssistant, entry_id: str, device_id_slug: str) -> None: + """Initialize RoborockMapStorage.""" + self._hass = hass + self._path_prefix = ( + _storage_path_prefix(hass, entry_id) / MAPS_PATH / device_id_slug + ) + + async def async_load_map(self, map_flag: int) -> bytes | None: + """Load maps from disk.""" + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + return await self._hass.async_add_executor_job(self._load_map, filename) + + def _load_map(self, filename: Path) -> bytes | None: + """Load maps from disk.""" + if not filename.exists(): + return None + try: + return filename.read_bytes() + except OSError as err: + _LOGGER.debug("Unable to read map file: %s %s", filename, err) + return None + + async def async_save_map(self, map_flag: int, content: bytes) -> None: + """Write map if it should be updated.""" + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + await self._hass.async_add_executor_job(self._save_map, filename, content) + + def _save_map(self, filename: Path, content: bytes) -> None: + """Write the map to disk.""" + _LOGGER.debug("Saving map to disk: %s", filename) + try: + filename.parent.mkdir(parents=True, exist_ok=True) + except OSError as err: + _LOGGER.error("Unable to create map directory: %s %s", filename, err) + return + try: + filename.write_bytes(content) + except OSError as err: + _LOGGER.error("Unable to write map file: %s %s", filename, err) + + +async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None: + """Remove all map storage associated with a config entry.""" + + def remove(path_prefix: Path) -> None: + try: + if path_prefix.exists(): + shutil.rmtree(path_prefix, ignore_errors=True) + except OSError as err: + _LOGGER.error("Unable to remove map files in %s: %s", path_prefix, err) + + path_prefix = _storage_path_prefix(hass, entry_id) + _LOGGER.debug("Removing maps from disk store: %s", path_prefix) + await hass.async_add_executor_job(remove, path_prefix) diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py index a06226d22ee..20ae0708c15 100644 --- a/homeassistant/components/rocketchat/notify.py +++ b/homeassistant/components/rocketchat/notify.py @@ -19,7 +19,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_PASSWORD, CONF_ROOM, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/romy/config_flow.py b/homeassistant/components/romy/config_flow.py index 6bb5c337b29..48558cd98c7 100644 --- a/homeassistant/components/romy/config_flow.py +++ b/homeassistant/components/romy/config_flow.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index d55a260e53a..ae5577da4e4 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -3,10 +3,10 @@ from __future__ import annotations from homeassistant.const import ATTR_CONNECTIONS -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import roomba_reported_state from .const import DOMAIN diff --git a/homeassistant/components/roon/config_flow.py b/homeassistant/components/roon/config_flow.py index b896f6775ae..3421cbf646c 100644 --- a/homeassistant/components/roon/config_flow.py +++ b/homeassistant/components/roon/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( AUTHENTICATE_TIMEOUT, diff --git a/homeassistant/components/route53/__init__.py b/homeassistant/components/route53/__init__.py index 92094b0b608..2c9824d0628 100644 --- a/homeassistant/components/route53/__init__.py +++ b/homeassistant/components/route53/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_TTL, CONF_ZONE from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py index 89624c922e6..98d0e1bf790 100644 --- a/homeassistant/components/rss_feed_template/__init__.py +++ b/homeassistant/components/rss_feed_template/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index 654288927d3..70fe7919edb 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/russound_rnet/media_player.py b/homeassistant/components/russound_rnet/media_player.py index f8369ed64ca..48808930d9f 100644 --- a/homeassistant/components/russound_rnet/media_player.py +++ b/homeassistant/components/russound_rnet/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index fee459340f3..1f68781a3a2 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -11,8 +11,7 @@ import voluptuous as vol from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import config_validation as cv -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index c8b40fd5476..89b6658c418 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.start import async_at_start diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 30ca44fe3ee..20dc9c1256a 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.collection import ( CollectionEntity, DictStorageCollection, @@ -28,7 +29,6 @@ from homeassistant.helpers.collection import ( YamlCollection, sync_entity_lifecycle, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index b319b21be0c..936ef9ee91e 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -13,7 +13,7 @@ from pyschlage.log import LockLog from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL diff --git a/homeassistant/components/schluter/__init__.py b/homeassistant/components/schluter/__init__.py index 907841a2e5e..f7a8b631a05 100644 --- a/homeassistant/components/schluter/__init__.py +++ b/homeassistant/components/schluter/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index ff991c5f348..68a8cf62fe4 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -19,8 +19,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + discovery, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 54067055a69..0fdf5d96445 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 14104ad0219..dd293726484 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -39,7 +39,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/scsgate/__init__.py b/homeassistant/components/scsgate/__init__.py index 9aabb315942..636c157b076 100644 --- a/homeassistant/components/scsgate/__init__.py +++ b/homeassistant/components/scsgate/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE, CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/scsgate/cover.py b/homeassistant/components/scsgate/cover.py index b6d3317555c..4c4d2c2949a 100644 --- a/homeassistant/components/scsgate/cover.py +++ b/homeassistant/components/scsgate/cover.py @@ -18,7 +18,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/scsgate/light.py b/homeassistant/components/scsgate/light.py index 23b73a0fd6b..0addbda9e09 100644 --- a/homeassistant/components/scsgate/light.py +++ b/homeassistant/components/scsgate/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/scsgate/switch.py b/homeassistant/components/scsgate/switch.py index abc906a5533..4607d65ac7a 100644 --- a/homeassistant/components/scsgate/switch.py +++ b/homeassistant/components/scsgate/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py index a3827a23d41..1801d34d182 100644 --- a/homeassistant/components/select/device_action.py +++ b/homeassistant/components/select/device_action.py @@ -19,8 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/sendgrid/notify.py b/homeassistant/components/sendgrid/notify.py index 86f01804574..4dbb95085cb 100644 --- a/homeassistant/components/sendgrid/notify.py +++ b/homeassistant/components/sendgrid/notify.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONTENT_TYPE_TEXT_PLAIN, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py index a09401473b2..4d43408397f 100644 --- a/homeassistant/components/serial/sensor.py +++ b/homeassistant/components/serial/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index b454424591d..570d1ac0d63 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py index ad8b26f7034..5165d3d4798 100644 --- a/homeassistant/components/sesame/lock.py +++ b/homeassistant/components/sesame/lock.py @@ -13,7 +13,7 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, CONF_API_KEY from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/seven_segments/image_processing.py b/homeassistant/components/seven_segments/image_processing.py index 63fd27e0dd0..bda17b75081 100644 --- a/homeassistant/components/seven_segments/image_processing.py +++ b/homeassistant/components/seven_segments/image_processing.py @@ -17,7 +17,7 @@ from homeassistant.components.image_processing import ( ) from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 873d3fbd290..332d95b0a3e 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -16,8 +16,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 7140c79fbb6..c4420783bbb 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -186,7 +186,7 @@ RPC_NUMBERS: Final = { mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get( config["meta"]["ui"]["view"], NumberMode.BOX ), - step_fn=lambda config: config["meta"]["ui"]["step"], + step_fn=lambda config: config["meta"]["ui"].get("step"), # If the unit is not set, the device sends an empty string unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] @@ -208,7 +208,7 @@ RPC_NUMBERS: Final = { method_params_fn=lambda idx, value: { "id": idx, "method": "Trv.SetPosition", - "params": {"id": 0, "pos": value}, + "params": {"id": 0, "pos": int(value)}, }, removal_condition=lambda config, _status, key: config[key].get("enable", True) is True, diff --git a/homeassistant/components/shodan/sensor.py b/homeassistant/components/shodan/sensor.py index 867b58ad1ba..ef0f4dafd83 100644 --- a/homeassistant/components/shodan/sensor.py +++ b/homeassistant/components/shodan/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 531bbf37980..4ce596e72f0 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -17,7 +17,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, Platform from homeassistant.core import Context, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonValueType, load_json_array diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index 1a6370f4168..118287f70d2 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -3,8 +3,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, intent from . import DOMAIN, EVENT_SHOPPING_LIST_UPDATED diff --git a/homeassistant/components/sigfox/sensor.py b/homeassistant/components/sigfox/sensor.py index 8f9190e4436..aece5675cbc 100644 --- a/homeassistant/components/sigfox/sensor.py +++ b/homeassistant/components/sigfox/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py index acc8309af26..222b61456c4 100644 --- a/homeassistant/components/sighthound/image_processing.py +++ b/homeassistant/components/sighthound/image_processing.py @@ -22,10 +22,10 @@ from homeassistant.const import ( CONF_SOURCE, ) from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.pil import draw_box _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py index 53a255da5ff..bc007eaa689 100644 --- a/homeassistant/components/signal_messenger/notify.py +++ b/homeassistant/components/signal_messenger/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sinch/notify.py b/homeassistant/components/sinch/notify.py index 16780a05704..8c906d26c23 100644 --- a/homeassistant/components/sinch/notify.py +++ b/homeassistant/components/sinch/notify.py @@ -23,7 +23,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_SENDER from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType DOMAIN = "sinch" diff --git a/homeassistant/components/sisyphus/__init__.py b/homeassistant/components/sisyphus/__init__.py index da8d670d412..1406826e471 100644 --- a/homeassistant/components/sisyphus/__init__.py +++ b/homeassistant/components/sisyphus/__init__.py @@ -8,8 +8,8 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index b0ad48ed985..7507175b321 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -14,8 +14,8 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py index a55dfb2a52b..13cddf99332 100644 --- a/homeassistant/components/sky_remote/config_flow.py +++ b/homeassistant/components/sky_remote/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 6cb5064b40e..650e62bc4a1 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index d53555ba82a..ca8c9830818 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ATTR_SNOOZE, DOMAIN, SLACK_DATA from .entity import SlackEntity diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 6506be06e72..4f54b4cd305 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -17,9 +17,8 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, PRESSURE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, IS_IN_BED, SLEEP_NUMBER diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 4b3e01a79a8..3f5eb635989 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -11,8 +11,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import CONF_GROUP, DOMAIN, GROUPS diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index 0d043804c3d..0e1e99aa444 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -9,8 +9,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index 9d847003a59..48b169c104e 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import REVOLUTIONS_PER_MINUTE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .coordinator import SmartyConfigEntry, SmartyCoordinator from .entity import SmartyEntity diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 5b38ec4a89e..6be36439e9f 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -144,11 +144,15 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): async def _internal_update_data(self) -> SmFwData: """Fetch data from the SMLIGHT device.""" info = await self.client.get_info() + esp_firmware = None + zb_firmware = None - return SmFwData( - info=info, - esp_firmware=await self.client.get_firmware_version(info.fw_channel), - zb_firmware=await self.client.get_firmware_version( + try: + esp_firmware = await self.client.get_firmware_version(info.fw_channel) + zb_firmware = await self.client.get_firmware_version( info.fw_channel, device=info.model, mode="zigbee" - ), - ) + ) + except SmlightConnectionError as err: + self.async_set_update_error(err) + + return SmFwData(info=info, esp_firmware=esp_firmware, zb_firmware=zb_firmware) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3a8578c8a59..9410e54cee1 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.6"], + "requirements": ["pysmlight==0.2.0"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 5d19a705d87..e86b22690a4 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -34,10 +34,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.ssl import client_context from .const import ( diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 4c2b2b25ad8..f69c844f191 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -24,7 +24,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 4586d0600e9..0baecd68ec4 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -37,7 +37,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 2f9f8b0bfb7..fd405567d60 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -44,7 +44,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index a7940aa34b5..80c418ef132 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 11f268db32a..bf2bc849111 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -19,8 +19,8 @@ from solarlog_cli.solarlog_models import SolarlogData from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify diff --git a/homeassistant/components/solax/config_flow.py b/homeassistant/components/solax/config_flow.py index e6c60667869..5baead641fc 100644 --- a/homeassistant/components/solax/config_flow.py +++ b/homeassistant/components/solax/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index 9ffe5539ff3..127b51338ee 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -9,7 +9,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import API, DEVICES, DOMAIN, HOST, PORT diff --git a/homeassistant/components/sonarr/coordinator.py b/homeassistant/components/sonarr/coordinator.py index 2d807bcf140..25fc736212b 100644 --- a/homeassistant/components/sonarr/coordinator.py +++ b/homeassistant/components/sonarr/coordinator.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, DOMAIN, LOGGER diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index f25c885ed84..fa7d0aa7756 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import SonarrDataT, SonarrDataUpdateCoordinator diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 98bff8d2934..d530fa21e39 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -34,7 +34,11 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval -from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -503,7 +507,7 @@ class SonosDiscoveryManager: def _async_ssdp_discovered_player( self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: - uid = info.upnp[ssdp.ATTR_UPNP_UDN] + uid = info.upnp[ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): return uid = uid[5:] @@ -522,7 +526,7 @@ class SonosDiscoveryManager: cast(str, urlparse(info.ssdp_location).hostname), uid, info.ssdp_headers.get("X-RINCON-BOOTSEQ"), - cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)), + cast(str, info.upnp.get(ATTR_UPNP_MODEL_NAME)), None, ) diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 98dc8b8b752..a9a76b3b4d0 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -8,7 +8,7 @@ import logging from soco.core import SoCo -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py index e018c06e050..f024c4ef4f7 100644 --- a/homeassistant/components/sony_projector/switch.py +++ b/homeassistant/components/sony_projector/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/soundtouch/__init__.py b/homeassistant/components/soundtouch/__init__.py index c35c1e6f9c3..49750bc9baf 100644 --- a/homeassistant/components/soundtouch/__init__.py +++ b/homeassistant/components/soundtouch/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 90281fe311c..6ef643488ad 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -5,6 +5,7 @@ import math import voluptuous as vol +from homeassistant import core as ha from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.const import ( ATTR_ENTITY_ID, @@ -21,11 +22,10 @@ from homeassistant.const import ( CONF_STATE, CONF_URL, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util ATTR_ADDRESS = "address" ATTR_SPACEFED = "spacefed" diff --git a/homeassistant/components/spc/__init__.py b/homeassistant/components/spc/__init__.py index 3d9467f2041..2fed542e382 100644 --- a/homeassistant/components/spc/__init__.py +++ b/homeassistant/components/spc/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/splunk/__init__.py b/homeassistant/components/splunk/__init__.py index 4294020eeee..6ef8fed78d6 100644 --- a/homeassistant/components/splunk/__init__.py +++ b/homeassistant/components/splunk/__init__.py @@ -19,9 +19,8 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index a86544d883e..8b8539d715a 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -18,7 +18,7 @@ from spotifyaio import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 71e3671ce96..1b9e8502209 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -24,8 +24,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 282323d8b7b..063919179ac 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 5fc4872a754..62e02426fcb 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -25,8 +25,8 @@ from homeassistant.const import ( UnitOfInformation, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/statsd/__init__.py b/homeassistant/components/statsd/__init__.py index 50b74b20028..4e8e5b7f942 100644 --- a/homeassistant/components/statsd/__init__.py +++ b/homeassistant/components/statsd/__init__.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 80c1dad3ee8..94a3bd1058b 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 2772fc2d30e..8fa4c69ac5a 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -33,7 +33,7 @@ from yarl import URL from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.async_ import create_eager_task diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 5eeb40630f8..313fc1f24c5 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN from .coordinator import StreamlabsCoordinator diff --git a/homeassistant/components/sun/trigger.py b/homeassistant/components/sun/trigger.py index 7724816d636..71498990b6f 100644 --- a/homeassistant/components/sun/trigger.py +++ b/homeassistant/components/sun/trigger.py @@ -11,7 +11,7 @@ from homeassistant.const import ( SUN_EVENT_SUNRISE, ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_sunrise, async_track_sunset from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py index 24189fb7de0..c443e1e63df 100644 --- a/homeassistant/components/supervisord/sensor.py +++ b/homeassistant/components/supervisord/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 8f04b5b662e..62f9b4b232d 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -11,8 +11,8 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py index 3d88182eaa4..897b440a934 100644 --- a/homeassistant/components/swiss_hydrological_data/sensor.py +++ b/homeassistant/components/swiss_hydrological_data/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 58d674f0c26..4dc6efc2e85 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -11,8 +11,8 @@ from opendata_transport.exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( DurationSelector, SelectSelector, diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index c4cf2390dd0..81322117a6f 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -15,7 +15,7 @@ from opendata_transport.exceptions import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType from .const import CONNECTIONS_COUNT, DEFAULT_UPDATE_TIME, DOMAIN diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py index 704479b77d6..e41901337f4 100644 --- a/homeassistant/components/swiss_public_transport/helper.py +++ b/homeassistant/components/swiss_public_transport/helper.py @@ -6,7 +6,7 @@ from typing import Any from opendata_transport import OpendataTransport -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_DESTINATION, diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 66537a4311e..842dc657817 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/light.py b/homeassistant/components/switch/light.py index 48d555e6616..276496ce614 100644 --- a/homeassistant/components/switch/light.py +++ b/homeassistant/components/switch/light.py @@ -21,8 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index b1a71665222..a2a3ecf0df9 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -13,9 +13,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback 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 -import homeassistant.helpers.device_registry as dr -import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import SwitchBeeCoordinator diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py index c8d3d58ee09..b2cd53398ab 100644 --- a/homeassistant/components/switchbee/config_flow.py +++ b/homeassistant/components/switchbee/config_flow.py @@ -13,8 +13,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 4e05e9e9a1e..9e996649e8c 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -4,7 +4,7 @@ from typing import Any from switchbot_api import AirConditionerCommands -import homeassistant.components.climate as FanState +from homeassistant.components import climate as FanState from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, diff --git a/homeassistant/components/switchmate/switch.py b/homeassistant/components/switchmate/switch.py index 8484eb5a2d1..0b449c65194 100644 --- a/homeassistant/components/switchmate/switch.py +++ b/homeassistant/components/switchmate/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/synology_chat/notify.py b/homeassistant/components/synology_chat/notify.py index 38c302b7968..37ea3238a06 100644 --- a/homeassistant/components/synology_chat/notify.py +++ b/homeassistant/components/synology_chat/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_RESOURCE, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType ATTR_FILE_URL = "file_url" diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index eed6af758ba..62a1b97b717 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -39,7 +39,7 @@ async def async_get_backup_agents( return [] syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN] return [ - SynologyDSMBackupAgent(hass, entry) + SynologyDSMBackupAgent(hass, entry, entry.unique_id) for entry in entries if entry.unique_id is not None and (syno_data := syno_datas.get(entry.unique_id)) @@ -76,11 +76,12 @@ class SynologyDSMBackupAgent(BackupAgent): domain = DOMAIN - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, unique_id: str) -> None: """Initialize the Synology DSM backup agent.""" super().__init__() LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id) self.name = entry.title + self.unique_id = unique_id self.path = ( f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}" ) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 30f5078f19d..b4453366718 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -40,8 +40,8 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 357de10b5b8..30d1260ef32 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -59,6 +59,8 @@ def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator base class for synology_dsm.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -68,10 +70,10 @@ class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): ) -> None: """Initialize synology_dsm DataUpdateCoordinator.""" self.api = api - self.entry = entry super().__init__( hass, _LOGGER, + config_entry=entry, name=f"{entry.title} {self.__class__.__name__}", update_interval=update_interval, ) @@ -174,7 +176,7 @@ class SynologyDSMCameraUpdateCoordinator( ): async_dispatcher_send( self.hass, - f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.entry.entry_id}_{cam_id}", + f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.config_entry.entry_id}_{cam_id}", cam_data_new.live_view.rtsp, ) diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 3d64f908256..d6d40be3fea 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -70,7 +70,13 @@ "data": { "scan_interval": "Minutes between scans", "timeout": "Timeout (seconds)", - "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)" + "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)", + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]" + }, + "data_description": { + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]" } } } diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 3e0e7add185..b916be84acf 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 191a2b5feb8..facfb270627 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -16,7 +16,7 @@ from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType type KeyType = tuple[str, tuple[str, int], tuple[str, int, str] | None] diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 47c1d14ce60..8d42596d3db 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -13,14 +13,16 @@ from homeassistant.components import websocket_api from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + collection, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from homeassistant.util.hass_dict import HassKey from .const import DEFAULT_NAME, DEVICE_ID, DOMAIN, EVENT_TAG_SCANNED, LOGGER, TAG_ID diff --git a/homeassistant/components/tami4/config_flow.py b/homeassistant/components/tami4/config_flow.py index 72b19470f45..a58c801c403 100644 --- a/homeassistant/components/tami4/config_flow.py +++ b/homeassistant/components/tami4/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import CONF_PHONE, CONF_REFRESH_TOKEN, DOMAIN diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index 6d4327a1d06..e9377e346d4 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index a500549a648..b2b60db9675 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -17,11 +17,7 @@ async def async_setup_entry( """Set a tankerkoenig configuration entry up.""" hass.data.setdefault(DOMAIN, {}) - coordinator = TankerkoenigDataUpdateCoordinator( - hass, - name=entry.unique_id or DOMAIN, - update_interval=DEFAULT_SCAN_INTERVAL, - ) + coordinator = TankerkoenigDataUpdateCoordinator(hass, entry, DEFAULT_SCAN_INTERVAL) await coordinator.async_setup() await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 509f293665d..8796ae46ab7 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -31,8 +31,8 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( LocationSelector, NumberSelector, diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 17e94f62fe9..1f73d0577b3 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_FUEL_TYPES, CONF_STATIONS +from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf def __init__( self, hass: HomeAssistant, - name: str, + config_entry: TankerkoenigConfigEntry, update_interval: int, ) -> None: """Initialize the data object.""" @@ -47,7 +47,8 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf super().__init__( hass=hass, logger=_LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.unique_id or DOMAIN, update_interval=timedelta(minutes=update_interval), ) diff --git a/homeassistant/components/tapsaff/binary_sensor.py b/homeassistant/components/tapsaff/binary_sensor.py index 0eb612bdc8e..beba9c91538 100644 --- a/homeassistant/components/tapsaff/binary_sensor.py +++ b/homeassistant/components/tapsaff/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_LOCATION, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 8a4b501af05..22cdf1a5ff0 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -14,9 +14,9 @@ from homeassistant.components import binary_sensor from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import event as evt from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW diff --git a/homeassistant/components/tcp/common.py b/homeassistant/components/tcp/common.py index a89cd999ddd..1263effa96b 100644 --- a/homeassistant/components/tcp/common.py +++ b/homeassistant/components/tcp/common.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_BUFFER_SIZE, diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index f9ebb29dd04..fec59d1c596 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -22,8 +22,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 3eb3c71a0bb..9bd360f5e41 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -109,13 +109,12 @@ class PushBot(BaseTelegramBotEntity): else: _LOGGER.debug("telegram webhook status: %s", current_status) - if current_status and current_status["url"] != self.webhook_url: - result = await self._try_to_set_webhook() - if result: - _LOGGER.debug("Set new telegram webhook %s", self.webhook_url) - else: - _LOGGER.error("Set telegram webhook failed %s", self.webhook_url) - return False + result = await self._try_to_set_webhook() + if result: + _LOGGER.debug("Set new telegram webhook %s", self.webhook_url) + else: + _LOGGER.error("Set telegram webhook failed %s", self.webhook_url) + return False return True diff --git a/homeassistant/components/tellstick/__init__.py b/homeassistant/components/tellstick/__init__.py index 9d120b7aaa8..6ccc1f14b5f 100644 --- a/homeassistant/components/tellstick/__init__.py +++ b/homeassistant/components/tellstick/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index 1e27511bd84..c777aa6f01f 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py index 0178a6521c4..0fa1076c943 100644 --- a/homeassistant/components/telnet/switch.py +++ b/homeassistant/components/telnet/switch.py @@ -26,7 +26,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 7b7b5eb9b29..15a73cf3de5 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -53,6 +53,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _reload_config(call: Event | ServiceCall) -> None: """Reload top-level + platforms.""" + await async_get_blueprints(hass).async_reset_cache() try: unprocessed_conf = await conf_util.async_hass_config_yaml(hass) except HomeAssistantError as err: diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index aa1f99f0423..a67e2969f9a 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -28,8 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 922f1d88ffb..3c6e4899502 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -40,8 +40,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import selector, template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 2642ede9c3a..306b4405c6a 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 7720ef7e1b3..6ed525fd45f 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index f194154a50c..0804f92e46d 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index d025f052732..8f9edca5976 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -33,7 +33,7 @@ from homeassistant.core import ( validate_state, ) from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( TrackTemplate, diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 19029cc708b..b977f4e659a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index f4a3a7bfe07..15addd3513d 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -26,8 +26,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, ) from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.pil import draw_box diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 945c6351cfc..634e8f845f9 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -25,13 +25,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN, LOGGER, MODELS diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index c806138c219..f907107fd40 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -298,7 +298,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "default": "mdi:ev-station" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index c438bfff50f..540ea2b7135 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -522,7 +522,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py index d602cff78c0..054ea84cbe1 100644 --- a/homeassistant/components/tesla_fleet/switch.py +++ b/homeassistant/components/tesla_fleet/switch.py @@ -16,6 +16,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TeslaFleetConfigEntry from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity @@ -32,6 +33,8 @@ class TeslaFleetSwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable scopes: list[Scope] + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( @@ -77,13 +80,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( ), scopes=[Scope.VEHICLE_CMDS], ), -) - -VEHICLE_CHARGE_DESCRIPTION = TeslaFleetSwitchEntityDescription( - key="charge_state_user_charge_enable_request", - on_func=lambda api: api.charge_start(), - off_func=lambda api: api.charge_stop(), - scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + TeslaFleetSwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_user_charge_enable_request", + on_func=lambda api: api.charge_start(), + off_func=lambda api: api.charge_stop(), + value_func=lambda state: state in {"Starting", "Charging"}, + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + ), ) @@ -103,12 +107,6 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS ), - ( - TeslaFleetChargeSwitchEntity( - vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes - ) - for vehicle in entry.runtime_data.vehicles - ), ( TeslaFleetChargeFromGridSwitchEntity( energysite, @@ -144,16 +142,18 @@ class TeslaFleetVehicleSwitchEntity(TeslaFleetVehicleEntity, TeslaFleetSwitchEnt scopes: list[Scope], ) -> None: """Initialize the Switch.""" - super().__init__(data, description.key) self.entity_description = description self.scoped = any(scope in scopes for scope in description.scopes) + super().__init__(data, description.key) + if description.unique_id: + self._attr_unique_id = f"{data.vin}-{description.unique_id}" def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" if self._value is None: self._attr_is_on = None else: - self._attr_is_on = bool(self._value) + self._attr_is_on = self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -172,17 +172,6 @@ class TeslaFleetVehicleSwitchEntity(TeslaFleetVehicleEntity, TeslaFleetSwitchEnt self.async_write_ha_state() -class TeslaFleetChargeSwitchEntity(TeslaFleetVehicleSwitchEntity): - """Entity class for TeslaFleet charge switch.""" - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - if self._value is None: - self._attr_is_on = self.get("charge_state_charge_enable_request") - else: - self._attr_is_on = self._value - - class TeslaFleetChargeFromGridSwitchEntity( TeslaFleetEnergyInfoEntity, TeslaFleetSwitchEntity ): diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index b9cbc64dcd9..6e60b34825f 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -18,9 +18,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 6559acf89dc..9996a508177 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -291,7 +291,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "default": "mdi:ev-station" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index 4600391145b..18b88273bec 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations +from itertools import chain from typing import Any from tesla_fleet_api.const import Scope @@ -10,10 +11,15 @@ from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .const import DOMAIN -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -30,31 +36,38 @@ async def async_setup_entry( """Set up the Teslemetry lock platform from a config entry.""" async_add_entities( - klass(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) - for klass in ( - TeslemetryVehicleLockEntity, - TeslemetryCableLockEntity, + chain( + ( + TeslemetryPollingVehicleLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingVehicleLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingCableLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingCableLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), ) - for vehicle in entry.runtime_data.vehicles ) -class TeslemetryVehicleLockEntity(TeslemetryVehicleEntity, LockEntity): - """Lock entity for Teslemetry.""" - - def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: - """Initialize the lock.""" - super().__init__(data, "vehicle_state_locked") - self.scoped = scoped - - def _async_update_attrs(self) -> None: - """Update entity attributes.""" - self._attr_is_locked = self._value +class TeslemetryVehicleLockEntity(TeslemetryRootEntity, LockEntity): + """Base vehicle lock entity for Teslemetry.""" async def async_lock(self, **kwargs: Any) -> None: """Lock the doors.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_lock()) self._attr_is_locked = True self.async_write_ha_state() @@ -62,27 +75,65 @@ class TeslemetryVehicleLockEntity(TeslemetryVehicleEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the doors.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_unlock()) self._attr_is_locked = False self.async_write_ha_state() -class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity): - """Cable Lock entity for Teslemetry.""" +class TeslemetryPollingVehicleLockEntity( + TeslemetryVehicleEntity, TeslemetryVehicleLockEntity +): + """Polling vehicle lock entity for Teslemetry.""" - def __init__( - self, - data: TeslemetryVehicleData, - scoped: bool, - ) -> None: - """Initialize the lock.""" - super().__init__(data, "charge_state_charge_port_latch") + def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "vehicle_state_locked", + ) self.scoped = scoped def _async_update_attrs(self) -> None: """Update entity attributes.""" - self._attr_is_locked = self._value == ENGAGED + self._attr_is_locked = self._value + + +class TeslemetryStreamingVehicleLockEntity( + TeslemetryVehicleStreamEntity, TeslemetryVehicleLockEntity, RestoreEntity +): + """Streaming vehicle lock entity for Teslemetry.""" + + def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "vehicle_state_locked", + ) + self.scoped = scoped + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state == "locked": + self._attr_is_locked = True + elif state.state == "unlocked": + self._attr_is_locked = False + + # Add streaming listener + self.async_on_remove(self.vehicle.stream_vehicle.listen_Locked(self._callback)) + + def _callback(self, value: bool | None) -> None: + """Update entity attributes.""" + self._attr_is_locked = value + self.async_write_ha_state() + + +class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity): + """Base cable Lock entity for Teslemetry.""" async def async_lock(self, **kwargs: Any) -> None: """Charge cable Lock cannot be manually locked.""" @@ -95,7 +146,70 @@ class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock charge cable lock.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_open()) self._attr_is_locked = False self.async_write_ha_state() + + +class TeslemetryPollingCableLockEntity( + TeslemetryVehicleEntity, TeslemetryCableLockEntity +): + """Polling cable lock entity for Teslemetry.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "charge_state_charge_port_latch", + ) + self.scoped = scoped + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + if self._value is None: + self._attr_is_locked = None + self._attr_is_locked = self._value == ENGAGED + + +class TeslemetryStreamingCableLockEntity( + TeslemetryVehicleStreamEntity, TeslemetryCableLockEntity, RestoreEntity +): + """Streaming cable lock entity for Teslemetry.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "charge_state_charge_port_latch", + ) + self.scoped = scoped + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state == "locked": + self._attr_is_locked = True + elif state.state == "unlocked": + self._attr_is_locked = False + + # Add streaming listener + self.async_on_remove( + self.vehicle.stream_vehicle.listen_ChargePortLatch(self._callback) + ) + + def _callback(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_is_locked = None if value is None else value == ENGAGED + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 9ba9c28b199..c44028f2da7 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -9,20 +9,33 @@ from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, + RestoreNumber, +) +from homeassistant.const import ( + PERCENTAGE, + PRECISION_WHOLE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfElectricCurrent, ) -from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from . import TeslemetryConfigEntry -from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -33,12 +46,22 @@ PARALLEL_UPDATES = 0 class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription): """Describes Teslemetry Number entity.""" - func: Callable[[VehicleSpecific, float], Awaitable[Any]] - native_min_value: float - native_max_value: float + func: Callable[[VehicleSpecific, int], Awaitable[Any]] min_key: str | None = None max_key: str + native_min_value: float + native_max_value: float scopes: list[Scope] + value_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], + Callable[[], None], + ] + max_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], Callable[[], None] + ] + | None + ) = None VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( @@ -52,7 +75,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( mode=NumberMode.AUTO, max_key="charge_state_charge_current_request_max", func=lambda api, value: api.set_charging_amps(value), - scopes=[Scope.VEHICLE_CHARGING_CMDS], + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + value_listener=lambda x, y: x.listen_ChargeCurrentRequest(y), + max_listener=lambda x, y: x.listen_ChargeCurrentRequestMax(y), ), TeslemetryNumberVehicleEntityDescription( key="charge_state_charge_limit_soc", @@ -62,10 +87,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=NumberDeviceClass.BATTERY, mode=NumberMode.AUTO, - min_key="charge_state_charge_limit_soc_min", max_key="charge_state_charge_limit_soc_max", func=lambda api, value: api.set_charge_limit(value), scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + value_listener=lambda x, y: x.listen_ChargeLimitSoc(y), ), ) @@ -76,16 +101,29 @@ class TeslemetryNumberBatteryEntityDescription(NumberEntityDescription): func: Callable[[EnergySpecific, float], Awaitable[Any]] requires: str | None = None + scopes: list[Scope] ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] = ( TeslemetryNumberBatteryEntityDescription( key="backup_reserve_percent", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=100, + device_class=NumberDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + scopes=[Scope.ENERGY_CMDS], func=lambda api, value: api.backup(int(value)), requires="components_battery", ), TeslemetryNumberBatteryEntityDescription( key="off_grid_vehicle_charging_reserve_percent", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=100, + device_class=NumberDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + scopes=[Scope.ENERGY_CMDS], func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)), requires="components_off_grid_vehicle_charging_reserve_supported", ), @@ -101,8 +139,14 @@ async def async_setup_entry( async_add_entities( chain( - ( # Add vehicle entities - TeslemetryVehicleNumberEntity( + ( + TeslemetryPollingNumberEntity( + vehicle, + description, + entry.runtime_data.scopes, + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingNumberEntity( vehicle, description, entry.runtime_data.scopes, @@ -110,7 +154,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS ), - ( # Add energy site entities + ( TeslemetryEnergyInfoNumberSensorEntity( energysite, description, @@ -125,11 +169,25 @@ async def async_setup_entry( ) -class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity): +class TeslemetryVehicleNumberEntity(TeslemetryRootEntity, NumberEntity): """Vehicle number entity base class.""" entity_description: TeslemetryNumberVehicleEntityDescription + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_scope(self.entity_description.scopes[0]) + await handle_vehicle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() + + +class TeslemetryPollingNumberEntity( + TeslemetryVehicleEntity, TeslemetryVehicleNumberEntity +): + """Vehicle polling number entity.""" + def __init__( self, data: TeslemetryVehicleData, @@ -148,26 +206,67 @@ class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity): """Update the attributes of the entity.""" self._attr_native_value = self._value - if (min_key := self.entity_description.min_key) is not None: - self._attr_native_min_value = self.get_number( - min_key, - self.entity_description.native_min_value, - ) - else: - self._attr_native_min_value = self.entity_description.native_min_value - self._attr_native_max_value = self.get_number( self.entity_description.max_key, self.entity_description.native_max_value, ) - async def async_set_native_value(self, value: float) -> None: - """Set new value.""" - value = int(value) - self.raise_for_scope(self.entity_description.scopes[0]) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.entity_description.func(self.api, value)) - self._attr_native_value = value + +class TeslemetryStreamingNumberEntity( + TeslemetryVehicleStreamEntity, TeslemetryVehicleNumberEntity, RestoreNumber +): + """Number entity for current charge.""" + + entity_description: TeslemetryNumberVehicleEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryNumberVehicleEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the Number entity.""" + self.scoped = any(scope in scopes for scope in description.scopes) + self.entity_description = description + self._attr_native_max_value = self.entity_description.native_max_value + super().__init__(data, description.key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (last_state := await self.async_get_last_state()) and ( + last_number_data := await self.async_get_last_number_data() + ): + if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self._attr_native_value = last_number_data.native_value + if last_number_data.native_max_value: + self._attr_native_max_value = last_number_data.native_max_value + + # Add listeners + self.async_on_remove( + self.entity_description.value_listener( + self.vehicle.stream_vehicle, self._value_callback + ) + ) + if self.entity_description.max_listener: + self.async_on_remove( + self.entity_description.max_listener( + self.vehicle.stream_vehicle, self._max_callback + ) + ) + + def _value_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + self._attr_native_value = None if value is None else value + self.async_write_ha_state() + + def _max_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + self._attr_native_max_value = ( + self.entity_description.native_max_value if value is None else value + ) self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 0fb0a6ee0e0..dd83ad04ed6 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from propcache.api import cached_property from teslemetry_stream import Signal @@ -369,8 +369,16 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( ), ) -ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class TeslemetryEnergySensorEntityDescription(SensorEntityDescription): + """Describes Teslemetry Sensor entity.""" + + value_fn: Callable[[StateType], StateType | datetime] = lambda x: x + + +ENERGY_LIVE_DESCRIPTIONS: tuple[TeslemetryEnergySensorEntityDescription, ...] = ( + TeslemetryEnergySensorEntityDescription( key="solar_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -378,7 +386,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="energy_left", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -387,7 +395,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="total_pack_energy", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -397,14 +405,15 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="percentage_charged", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="battery_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -412,7 +421,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="load_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -420,7 +429,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="grid_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -428,7 +437,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="grid_services_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -436,7 +445,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="generator_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -445,7 +454,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="island_status", device_class=SensorDeviceClass.ENUM, options=[ @@ -555,6 +564,7 @@ async def async_setup_entry( if energysite.live_coordinator for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" ) entities.extend( @@ -704,12 +714,12 @@ class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity): """Base class for Teslemetry energy site metric sensors.""" - entity_description: SensorEntityDescription + entity_description: TeslemetryEnergySensorEntityDescription def __init__( self, data: TeslemetryEnergyData, - description: SensorEntityDescription, + description: TeslemetryEnergySensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -718,7 +728,7 @@ class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" self._attr_available = not self.is_none - self._attr_native_value = self._value + self._attr_native_value = self.entity_description.value_fn(self._value) class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity): diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 8dc8b053712..68ad12a46b6 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -608,7 +608,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 6a1cff4c5da..f810dee8554 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -16,6 +16,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity @@ -32,6 +33,8 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable scopes: list[Scope] + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( @@ -77,13 +80,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), scopes=[Scope.VEHICLE_CMDS], ), -) - -VEHICLE_CHARGE_DESCRIPTION = TeslemetrySwitchEntityDescription( - key="charge_state_user_charge_enable_request", - on_func=lambda api: api.charge_start(), - off_func=lambda api: api.charge_stop(), - scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], + TeslemetrySwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_user_charge_enable_request", + on_func=lambda api: api.charge_start(), + off_func=lambda api: api.charge_stop(), + value_func=lambda state: state in {"Starting", "Charging"}, + scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], + ), ) @@ -104,12 +108,6 @@ async def async_setup_entry( for description in VEHICLE_DESCRIPTIONS if description.key in vehicle.coordinator.data ), - ( - TeslemetryChargeSwitchEntity( - vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes - ) - for vehicle in entry.runtime_data.vehicles - ), ( TeslemetryChargeFromGridSwitchEntity( energysite, @@ -145,13 +143,15 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt scopes: list[Scope], ) -> None: """Initialize the Switch.""" - super().__init__(data, description.key) self.entity_description = description self.scoped = any(scope in scopes for scope in description.scopes) + super().__init__(data, description.key) + if description.unique_id: + self._attr_unique_id = f"{data.vin}-{description.unique_id}" def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_is_on = bool(self._value) + self._attr_is_on = self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -170,17 +170,6 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt self.async_write_ha_state() -class TeslemetryChargeSwitchEntity(TeslemetryVehicleSwitchEntity): - """Entity class for Teslemetry charge switch.""" - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - if self._value is None: - self._attr_is_on = self.get("charge_state_charge_enable_request") - else: - self._attr_is_on = self._value - - class TeslemetryChargeFromGridSwitchEntity( TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity ): diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 4ac645a0270..8384bb3d8fb 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -459,7 +459,7 @@ } }, "switch": { - "charge_state_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_defrost_mode": { diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index f0088a4444f..dba00a85bb2 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -27,6 +27,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TessieConfigEntry from .entity import TessieEnergyEntity, TessieEntity @@ -40,6 +41,8 @@ class TessieSwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( @@ -63,12 +66,13 @@ DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( on_func=lambda: start_steering_wheel_heater, off_func=lambda: stop_steering_wheel_heater, ), -) - -CHARGE_DESCRIPTION: TessieSwitchEntityDescription = TessieSwitchEntityDescription( - key="charge_state_charge_enable_request", - on_func=lambda: start_charging, - off_func=lambda: stop_charging, + TessieSwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_charge_enable_request", + on_func=lambda: start_charging, + off_func=lambda: stop_charging, + value_func=lambda state: state in {"Starting", "Charging"}, + ), ) PARALLEL_UPDATES = 0 @@ -89,10 +93,6 @@ async def async_setup_entry( for description in DESCRIPTIONS if description.key in vehicle.data_coordinator.data ), - ( - TessieChargeSwitchEntity(vehicle, CHARGE_DESCRIPTION) - for vehicle in entry.runtime_data.vehicles - ), ( TessieChargeFromGridSwitchEntity(energysite) for energysite in entry.runtime_data.energysites @@ -120,13 +120,15 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity): description: TessieSwitchEntityDescription, ) -> None: """Initialize the Switch.""" - super().__init__(vehicle, description.key) self.entity_description = description + super().__init__(vehicle, description.key) + if description.unique_id: + self._attr_unique_id = f"{vehicle.vin}-{description.unique_id}" @property def is_on(self) -> bool: """Return the state of the Switch.""" - return self._value + return self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -139,18 +141,6 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity): self.set((self.entity_description.key, False)) -class TessieChargeSwitchEntity(TessieSwitchEntity): - """Entity class for Tessie charge switch.""" - - @property - def is_on(self) -> bool: - """Return the state of the Switch.""" - - if (charge := self.get("charge_state_user_charge_enable_request")) is not None: - return charge - return self._value - - class TessieChargeFromGridSwitchEntity(TessieEnergyEntity, SwitchEntity): """Entity class for Charge From Grid switch.""" diff --git a/homeassistant/components/text/device_action.py b/homeassistant/components/text/device_action.py index 94269ac12fb..b1eca1e36b6 100644 --- a/homeassistant/components/text/device_action.py +++ b/homeassistant/components/text/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index e3aa9060787..9571597abe6 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index 7dc845ecf60..7ce0dfb9993 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thingspeak/__init__.py b/homeassistant/components/thingspeak/__init__.py index fdf06a9709a..1798e4f1de0 100644 --- a/homeassistant/components/thingspeak/__init__.py +++ b/homeassistant/components/thingspeak/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import event, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, event, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py index 4d28912e20d..ccdc1ada48e 100644 --- a/homeassistant/components/thinkingcleaner/sensor.py +++ b/homeassistant/components/thinkingcleaner/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_HOST, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index 76c7cdb0db2..8397eeedc23 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -17,7 +17,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index 4e44b2b1ffd..f003264b6d7 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 9b5c7ee1168..424b35b963b 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util, ssl as ssl_util diff --git a/homeassistant/components/tikteck/light.py b/homeassistant/components/tikteck/light.py index 26ffc0e7b6d..a3961cbb569 100644 --- a/homeassistant/components/tikteck/light.py +++ b/homeassistant/components/tikteck/light.py @@ -17,10 +17,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 245d10bebba..1e86a1ba6c6 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -17,11 +17,11 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import OPTION_TYPES diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 19b1de427ef..b0ade17b9c9 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -19,15 +19,14 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index 126c3128f91..cbf3b073578 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 62f9fafc02a..94581439ae9 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -22,8 +22,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index dfa8d2bd4e1..2cef5eea0cf 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType CONF_HTTP_ID = "http_id" diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py index 543046fac1c..8d4183e2961 100644 --- a/homeassistant/components/torque/sensor.py +++ b/homeassistant/components/torque/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_EMAIL, CONF_NAME, DEGREE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index e9d27341cb7..f7eec7c54f9 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index e08495f5c88..6986765b110 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -35,6 +35,10 @@ BINARY_SENSOR_DESCRIPTIONS: Final = ( key="overheated", device_class=BinarySensorDeviceClass.PROBLEM, ), + TPLinkBinarySensorEntityDescription( + key="overloaded", + device_class=BinarySensorDeviceClass.PROBLEM, + ), TPLinkBinarySensorEntityDescription( key="battery_low", device_class=BinarySensorDeviceClass.BATTERY, diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 0a4517b967d..4279a233d21 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -70,6 +70,23 @@ BUTTON_DESCRIPTIONS: Final = [ key="tilt_down", available_fn=lambda dev: dev.is_on, ), + TPLinkButtonEntityDescription(key="pair"), + TPLinkButtonEntityDescription(key="unpair"), + TPLinkButtonEntityDescription( + key="main_brush_reset", + ), + TPLinkButtonEntityDescription( + key="side_brush_reset", + ), + TPLinkButtonEntityDescription( + key="sensor_reset", + ), + TPLinkButtonEntityDescription( + key="filter_reset", + ), + TPLinkButtonEntityDescription( + key="charging_contacts_reset", + ), ] BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS} diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 61c1bf1cb9b..2df7101791a 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -4,7 +4,9 @@ from __future__ import annotations from typing import Final -from homeassistant.const import Platform, UnitOfTemperature +from kasa.smart.modules.clean import AreaUnit + +from homeassistant.const import Platform, UnitOfArea, UnitOfTemperature DOMAIN = "tplink" @@ -41,9 +43,12 @@ PLATFORMS: Final = [ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.VACUUM, ] UNIT_MAPPING = { "celsius": UnitOfTemperature.CELSIUS, "fahrenheit": UnitOfTemperature.FAHRENHEIT, + AreaUnit.Sqm: UnitOfArea.SQUARE_METERS, + AreaUnit.Sqft: UnitOfArea.SQUARE_FEET, } diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 186840e8faf..d1b4694779d 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -49,6 +49,12 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device + + # The iot HS300 allows a limited number of concurrent requests and + # fetching the emeter information requires separate ones, so child + # coordinators are created below in get_child_coordinator. + self._update_children = not isinstance(device, IotStrip) + super().__init__( hass, _LOGGER, @@ -68,7 +74,7 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch all device and sensor data from api.""" try: - await self.device.update(update_children=False) + await self.device.update(update_children=self._update_children) except AuthenticationError as ex: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index edef8bd83a0..15c07655e69 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -59,6 +59,7 @@ DEVICETYPES_WITH_SPECIALIZED_PLATFORMS = { DeviceType.Dimmer, DeviceType.Fan, DeviceType.Thermostat, + DeviceType.Vacuum, } # Primary features to always include even when the device type has its own platform @@ -109,6 +110,9 @@ class TPLinkModuleEntityDescription(TPLinkEntityDescription): unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( lambda device, desc: f"{legacy_device_id(device)}-{desc.key}" ) + entity_name_fn: ( + Callable[[Device, TPLinkModuleEntityDescription], str | None] | None + ) = None def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( @@ -549,7 +553,9 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): # the description should have a translation key. # HA logic is to name entities based on the following logic: # _attr_name > translation.name > description.name - if not description.translation_key: + if entity_name_fn := description.entity_name_fn: + self._attr_name = entity_name_fn(device, description) + elif not description.translation_key: if parent is None or parent.device_type is Device.Type.Hub: self._attr_name = None else: diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index e00e8f69467..73bb40a8386 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -32,7 +32,20 @@ }, "tilt_down": { "default": "mdi:chevron-down" - } + }, + "main_brush_reset": { + "default": "mdi:brush" + }, + "side_brush_reset": { + "default": "mdi:brush" + }, + "sensor_reset": { + "default": "mdi:eye-outline" + }, + "filter_reset": { + "default": "mdi:air-filter" + }, + "charging_contacts_reset": {} }, "select": { "light_preset": { @@ -113,6 +126,9 @@ "state": { "on": "mdi:baby-face" } + }, + "carpet_boost": { + "default": "mdi:rug" } }, "sensor": { @@ -130,6 +146,35 @@ }, "water_alert_timestamp": { "default": "mdi:clock-alert-outline" + }, + "main_brush_remaining": { + "default": "mdi:brush" + }, + "main_brush_used": { + "default": "mdi:brush" + }, + "side_brush_remaining": { + "default": "mdi:brush" + }, + "side_brush_used": { + "default": "mdi:brush" + }, + "filter_remaining": { + "default": "mdi:air-filter" + }, + "filter_used": { + "default": "mdi:air-filter" + }, + "sensor_remaining": { + "default": "mdi:eye-outline" + }, + "sensor_used": { + "default": "mdi:eye-outline" + }, + "charging_contacts_remaining": {}, + "charging_contacts_used": {}, + "vacuum_error": { + "default": "mdi:alert-circle" } }, "number": { @@ -150,6 +195,9 @@ }, "tilt_step": { "default": "mdi:unfold-more-horizontal" + }, + "clean_count": { + "default": "mdi:counter" } } }, diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index c1311c256df..718b5ed7120 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -28,7 +28,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index f55dfda1664..6f9eefbdabb 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -300,5 +300,6 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], + "quality_scale": "platinum", "requirements": ["python-kasa[speedups]==0.10.0"] } diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 0af2b7403e8..a9d002c0083 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -65,6 +65,14 @@ NUMBER_DESCRIPTIONS: Final = ( key="tilt_step", mode=NumberMode.BOX, ), + TPLinkNumberEntityDescription( + key="power_protection_threshold", + mode=NumberMode.SLIDER, + ), + TPLinkNumberEntityDescription( + key="clean_count", + mode=NumberMode.SLIDER, + ), ) NUMBER_DESCRIPTIONS_MAP = {desc.key: desc for desc in NUMBER_DESCRIPTIONS} diff --git a/homeassistant/components/tplink/quality_scale.yaml b/homeassistant/components/tplink/quality_scale.yaml index ced9cbcc831..f120945771c 100644 --- a/homeassistant/components/tplink/quality_scale.yaml +++ b/homeassistant/components/tplink/quality_scale.yaml @@ -44,12 +44,12 @@ rules: entity-category: done entity-disabled-by-default: done discovery: done - stale-devices: todo + stale-devices: done diagnostics: done exception-translations: done icon-translations: done reconfiguration-flow: done - dynamic-devices: todo + dynamic-devices: done discovery-update-info: done repair-issues: done docs-use-cases: done diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index aaba6b2674d..38aab26cf8b 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -2,10 +2,13 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from operator import methodcaller +from typing import TYPE_CHECKING, Any, cast from kasa import Feature +from kasa.smart.modules.clean import ErrorCode as VacuumError from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -14,6 +17,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -28,10 +32,15 @@ class TPLinkSensorEntityDescription( ): """Base class for a TPLink feature based sensor entity description.""" + #: Optional callable to convert the value + convert_fn: Callable[[Any], Any] | None = None + # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 +_TOTAL_SECONDS_METHOD_CALLER = methodcaller("total_seconds") + SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="current_consumption", @@ -115,6 +124,126 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="alarm_source", ), + # Vacuum cleaning records + TPLinkSensorEntityDescription( + key="clean_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="clean_area", + device_class=SensorDeviceClass.AREA, + ), + TPLinkSensorEntityDescription( + key="clean_progress", + ), + TPLinkSensorEntityDescription( + key="last_clean_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="last_clean_area", + device_class=SensorDeviceClass.AREA, + ), + TPLinkSensorEntityDescription( + key="last_clean_timestamp", + device_class=SensorDeviceClass.TIMESTAMP, + ), + TPLinkSensorEntityDescription( + key="total_clean_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="total_clean_area", + device_class=SensorDeviceClass.AREA, + ), + TPLinkSensorEntityDescription( + key="total_clean_count", + ), + TPLinkSensorEntityDescription( + key="main_brush_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="main_brush_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="side_brush_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="side_brush_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="filter_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="filter_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="sensor_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="sensor_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="charging_contacts_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="charging_contacts_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="vacuum_error", + device_class=SensorDeviceClass.ENUM, + options=[name.lower() for name in VacuumError._member_names_], + convert_fn=lambda x: x.name.lower(), + ), ) SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS} @@ -165,6 +294,9 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): # We probably do not need this, when we are rounding already? self._attr_suggested_display_precision = self._feature.precision_hint + if self.entity_description.convert_fn: + value = self.entity_description.convert_fn(value) + if TYPE_CHECKING: # pylint: disable-next=import-outside-toplevel from datetime import date, datetime diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index fa284a3cc83..ded4806a726 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -109,6 +109,9 @@ "overheated": { "name": "Overheated" }, + "overloaded": { + "name": "Overloaded" + }, "cloud_connection": { "name": "Cloud connection" }, @@ -138,6 +141,27 @@ }, "tilt_down": { "name": "Tilt down" + }, + "pair": { + "name": "Pair new device" + }, + "unpair": { + "name": "Unpair device" + }, + "main_brush_reset": { + "name": "Reset main brush consumable" + }, + "side_brush_reset": { + "name": "Reset side brush consumable" + }, + "sensor_reset": { + "name": "Reset sensor consumable" + }, + "filter_reset": { + "name": "Reset filter consumable" + }, + "charging_contacts_reset": { + "name": "Reset charging contacts consumable" } }, "camera": { @@ -192,6 +216,80 @@ }, "alarm_source": { "name": "Alarm source" + }, + "clean_area": { + "name": "Cleaning area" + }, + "clean_time": { + "name": "Cleaning time" + }, + "clean_progress": { + "name": "Cleaning progress" + }, + "total_clean_area": { + "name": "Total cleaning area" + }, + "total_clean_time": { + "name": "Total cleaning time" + }, + "total_clean_count": { + "name": "Total cleaning count" + }, + "last_clean_area": { + "name": "Last cleaned area" + }, + "last_clean_time": { + "name": "Last cleaned time" + }, + "last_clean_timestamp": { + "name": "Last clean start" + }, + "main_brush_remaining": { + "name": "Main brush remaining" + }, + "main_brush_used": { + "name": "Main brush used" + }, + "side_brush_remaining": { + "name": "Side brush remaining" + }, + "side_brush_used": { + "name": "Side brush used" + }, + "filter_remaining": { + "name": "Filter remaining" + }, + "filter_used": { + "name": "Filter used" + }, + "sensor_remaining": { + "name": "Sensor remaining" + }, + "sensor_used": { + "name": "Sensor used" + }, + "charging_contacts_remaining": { + "name": "Charging contacts remaining" + }, + "charging_contacts_used": { + "name": "Charging contacts used" + }, + "vacuum_error": { + "name": "Error", + "state": { + "ok": "No error", + "sidebrushstuck": "Side brush stuck", + "mainbrushstuck": "Main brush stuck", + "wheelblocked": "Wheel blocked", + "trapped": "Unable to move", + "trappedcliff": "Unable to move (cliff sensor)", + "dustbinremoved": "Missing dust bin", + "unabletomove": "Unable to move", + "lidarblocked": "Lidar blocked", + "unabletofinddock": "Unable to find dock", + "batterylow": "Low on battery", + "unknowninternal": "Unknown error, report to upstream" + } } }, "switch": { @@ -227,6 +325,9 @@ }, "baby_cry_detection": { "name": "Baby cry detection" + }, + "carpet_boost": { + "name": "Carpet boost" } }, "number": { @@ -242,11 +343,32 @@ "temperature_offset": { "name": "Temperature offset" }, + "power_protection_threshold": { + "name": "Power protection" + }, "pan_step": { "name": "Pan degrees" }, "tilt_step": { "name": "Tilt degrees" + }, + "clean_count": { + "name": "Clean count" + } + }, + "vacuum": { + "vacuum": { + "state_attributes": { + "fan_speed": { + "state": { + "quiet": "Quiet", + "standard": "Standard", + "turbo": "Turbo", + "max": "Max", + "ultra": "Ultra" + } + } + } } } }, diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 04ca95273af..f08753def26 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -74,6 +74,9 @@ SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( TPLinkSwitchEntityDescription( key="baby_cry_detection", ), + TPLinkSwitchEntityDescription( + key="carpet_boost", + ), ) SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py new file mode 100644 index 00000000000..c62cd1d27c8 --- /dev/null +++ b/homeassistant/components/tplink/vacuum.py @@ -0,0 +1,162 @@ +"""Support for TPLink vacuum.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from kasa import Device, Module +from kasa.smart.modules.clean import Clean, Status + +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + StateVacuumEntity, + StateVacuumEntityDescription, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TPLinkConfigEntry +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + +# Upstream state to VacuumActivity +STATUS_TO_ACTIVITY = { + Status.Idle: VacuumActivity.IDLE, + Status.Cleaning: VacuumActivity.CLEANING, + Status.GoingHome: VacuumActivity.RETURNING, + Status.Charging: VacuumActivity.DOCKED, + Status.Charged: VacuumActivity.DOCKED, + Status.Undocked: VacuumActivity.IDLE, + Status.Paused: VacuumActivity.PAUSED, + Status.Error: VacuumActivity.ERROR, +} + + +@dataclass(frozen=True, kw_only=True) +class TPLinkVacuumEntityDescription( + StateVacuumEntityDescription, TPLinkModuleEntityDescription +): + """Base class for vacuum entity description.""" + + +VACUUM_DESCRIPTIONS: tuple[TPLinkVacuumEntityDescription, ...] = ( + TPLinkVacuumEntityDescription( + key="vacuum", + translation_key="vacuum", + exists_fn=lambda dev, _: Module.Clean in dev.modules, + entity_name_fn=lambda _, __: None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up vacuum entities.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + device = parent_coordinator.device + + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkVacuumEntity, + descriptions=VACUUM_DESCRIPTIONS, + platform_domain=VACUUM_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) + + +class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): + """Representation of a tplink vacuum.""" + + _attr_supported_features = ( + VacuumEntityFeature.STATE + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.START + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.RETURN_HOME + ) + + entity_description: TPLinkVacuumEntityDescription + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkVacuumEntityDescription, + *, + parent: Device, + ) -> None: + """Initialize the vacuum entity.""" + super().__init__(device, coordinator, description, parent=parent) + self._vacuum_module: Clean = device.modules[Module.Clean] + if speaker := device.modules.get(Module.Speaker): + self._speaker_module = speaker + self._attr_supported_features |= VacuumEntityFeature.LOCATE + + if ( + fanspeed_feat := self._vacuum_module.get_feature("fan_speed_preset") + ) and fanspeed_feat.choices: + self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED + self._attr_fan_speed_list = [c.lower() for c in fanspeed_feat.choices] + + @async_refresh_after + async def async_start(self) -> None: + """Start cleaning.""" + await self._vacuum_module.start() + + @async_refresh_after + async def async_pause(self) -> None: + """Pause cleaning.""" + await self._vacuum_module.pause() + + @async_refresh_after + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return home.""" + await self._vacuum_module.return_home() + + @async_refresh_after + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + await self._vacuum_module.set_fan_speed_preset(fan_speed.capitalize()) + + async def async_locate(self, **kwargs: Any) -> None: + """Locate the device.""" + await self._speaker_module.locate() + + @property + def battery_level(self) -> int | None: + """Return battery level.""" + return self._vacuum_module.battery + + def _async_update_attrs(self) -> bool: + """Update the entity's attributes.""" + self._attr_activity = STATUS_TO_ACTIVITY.get(self._vacuum_module.status) + if self._vacuum_module.has_feature("fan_speed_preset"): + self._attr_fan_speed = self._vacuum_module.fan_speed_preset.lower() + return True diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index fe08c3db234..5b9bc2551b7 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -9,8 +9,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 9ff645ce4d6..bb0f3e5251a 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import ExtendedJSONEncoder from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/trace/models.py b/homeassistant/components/trace/models.py index e8ef417ca5f..3c503efdd28 100644 --- a/homeassistant/components/trace/models.py +++ b/homeassistant/components/trace/models.py @@ -15,9 +15,8 @@ from homeassistant.helpers.trace import ( trace_id_set, trace_set_child_id, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, uuid as uuid_util from homeassistant.util.limited_size_dict import LimitedSizeDict -import homeassistant.util.uuid as uuid_util type TraceData = dict[str, LimitedSizeDict[str, BaseTrace]] diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 0060310e6c2..92ed2ea8b82 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index a71691e6e90..e464d1a8142 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index da1fb0f7622..57d74eef78a 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -24,8 +24,8 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index 28b9a124fc6..f4316b887b3 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -15,8 +15,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index 5628274b967..49a11a57f65 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_MODE, CONF_API_KEY, CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index fe4a6541d9e..8193c5a67dc 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 9691ecf0744..e5ff5c64a8b 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -32,8 +32,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index bbe4d334def..6c7e521f3ef 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -43,7 +43,7 @@ from homeassistant.const import ( ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 54ea89cb674..6f0541734d1 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -27,8 +27,7 @@ from homeassistant.const import ( CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ( diff --git a/homeassistant/components/tts/notify.py b/homeassistant/components/tts/notify.py index 429d46660e7..c4c1bb1ae15 100644 --- a/homeassistant/components/tts/notify.py +++ b/homeassistant/components/tts/notify.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ATTR_LANGUAGE, ATTR_MEDIA_PLAYER_ENTITY_ID, ATTR_MESSAGE, DOMAIN diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index 69c509b9edf..606fb4913d1 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -8,7 +8,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import WASTE_TYPE_TO_DESCRIPTION from .coordinator import TwenteMilieuConfigEntry diff --git a/homeassistant/components/twilio/__init__.py b/homeassistant/components/twilio/__init__.py index b54af031af3..7ed65bdd54b 100644 --- a/homeassistant/components/twilio/__init__.py +++ b/homeassistant/components/twilio/__init__.py @@ -8,8 +8,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/twilio_call/notify.py b/homeassistant/components/twilio_call/notify.py index ab79ea9692d..4c432e0aeb5 100644 --- a/homeassistant/components/twilio_call/notify.py +++ b/homeassistant/components/twilio_call/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.components.twilio import DATA_TWILIO from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twilio_sms/notify.py b/homeassistant/components/twilio_sms/notify.py index 531fadcf259..a3f824f375f 100644 --- a/homeassistant/components/twilio_sms/notify.py +++ b/homeassistant/components/twilio_sms/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.components.twilio import DATA_TWILIO from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twitter/notify.py b/homeassistant/components/twitter/notify.py index eef51ca9613..f94bcd54459 100644 --- a/homeassistant/components/twitter/notify.py +++ b/homeassistant/components/twitter/notify.py @@ -21,7 +21,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index 285a176af0a..7c50b69683f 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index a86f7a1cc83..b06d0e24891 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -17,11 +17,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MODE, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 479055b84eb..3878e4c60eb 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -33,7 +33,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_MODEL_DESCRIPTION, diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 2ac47e67913..da5ca74fc37 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -24,10 +24,10 @@ from homeassistant.components.device_tracker import ( ScannerEntityDescription, ) from homeassistant.core import Event as core_Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import UnifiConfigEntry from .const import DOMAIN as UNIFI_DOMAIN diff --git a/homeassistant/components/unifi/hub/entity_helper.py b/homeassistant/components/unifi/hub/entity_helper.py index 782b026d6e4..b353ba6fc5c 100644 --- a/homeassistant/components/unifi/hub/entity_helper.py +++ b/homeassistant/components/unifi/hub/entity_helper.py @@ -10,7 +10,7 @@ from aiounifi.models.device import DeviceSetPoePortModeRequest from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util class UnifiEntityHelper: diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 1f54f56b194..f1ada9a01e0 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -17,7 +17,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import UnifiConfigEntry from .entity import ( diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 194a8575174..fd78c606043 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -48,8 +48,7 @@ from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from . import UnifiConfigEntry from .const import DEVICE_STATES diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 7741e57c82c..91e4a0222f6 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -44,9 +44,9 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from . import UnifiConfigEntry from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index d5e2e926114..1d7511aaae8 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/unifiled/light.py b/homeassistant/components/unifiled/light.py index 4e1981875f4..dbc73177f21 100644 --- a/homeassistant/components/unifiled/light.py +++ b/homeassistant/components/unifiled/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 335bc1e933d..90804559297 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -22,7 +22,7 @@ from uiprotect.data import ( ) from homeassistant.core import callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription diff --git a/homeassistant/components/upb/const.py b/homeassistant/components/upb/const.py index 16f2f1b7923..6e063c5a088 100644 --- a/homeassistant/components/upb/const.py +++ b/homeassistant/components/upb/const.py @@ -2,7 +2,7 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType DOMAIN = "upb" diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index c279be78666..bdaf01518f1 100644 --- a/homeassistant/components/upc_connect/device_tracker.py +++ b/homeassistant/components/upc_connect/device_tracker.py @@ -15,8 +15,8 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index 266542de9d6..25917d09096 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index ec65143b984..d68742522a0 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine, Sequence import dataclasses from datetime import datetime, timedelta @@ -10,8 +11,9 @@ from functools import partial import logging import os import sys -from typing import TYPE_CHECKING, Any, overload +from typing import Any, overload +from aiousbwatcher import AIOUSBWatcher, InotifyNotAvailableError from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol @@ -26,7 +28,7 @@ from homeassistant.core import ( HomeAssistant, callback as hass_callback, ) -from homeassistant.helpers import config_validation as cv, discovery_flow, system_info +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.deprecation import ( DeprecatedConstant, @@ -43,15 +45,13 @@ from .const import DOMAIN from .models import USBDevice from .utils import usb_device_from_port -if TYPE_CHECKING: - from pyudev import Device, MonitorObserver - _LOGGER = logging.getLogger(__name__) PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None] POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5) REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown +ADD_REMOVE_SCAN_COOLDOWN = 5 # 5 second cooldown to give devices a chance to register __all__ = [ "USBCallbackMatcher", @@ -255,15 +255,17 @@ class USBDiscovery: self.seen: set[tuple[str, ...]] = set() self.observer_active = False self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None + self._add_remove_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None self._request_callbacks: list[CALLBACK_TYPE] = [] self.initial_scan_done = False self._initial_scan_callbacks: list[CALLBACK_TYPE] = [] self._port_event_callbacks: set[PORT_EVENT_CALLBACK_TYPE] = set() self._last_processed_devices: set[USBDevice] = set() + self._scan_lock = asyncio.Lock() async def async_setup(self) -> None: """Set up USB Discovery.""" - if await self._async_supports_monitoring(): + if self._async_supports_monitoring(): await self._async_start_monitor() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) @@ -279,16 +281,19 @@ class USBDiscovery: if self._request_debouncer: self._request_debouncer.async_shutdown() - async def _async_supports_monitoring(self) -> bool: - info = await system_info.async_get_system_info(self.hass) - return not info.get("docker") + @hass_callback + def _async_supports_monitoring(self) -> bool: + return sys.platform == "linux" async def _async_start_monitor(self) -> None: """Start monitoring hardware.""" - if not await self._async_start_monitor_udev(): + try: + await self._async_start_aiousbwatcher() + except InotifyNotAvailableError as ex: _LOGGER.info( - "Falling back to periodic filesystem polling for development, libudev " - "is not present" + "Falling back to periodic filesystem polling for development, aiousbwatcher " + "is not available on this system: %s", + ex, ) self._async_start_monitor_polling() @@ -309,70 +314,27 @@ class USBDiscovery: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) - async def _async_start_monitor_udev(self) -> bool: - """Start monitoring hardware with pyudev. Returns True if successful.""" - if not sys.platform.startswith("linux"): - return False + async def _async_start_aiousbwatcher(self) -> None: + """Start monitoring hardware with aiousbwatcher. - if not ( - observer := await self.hass.async_add_executor_job( - self._get_monitor_observer - ) - ): - return False - - def _stop_observer(event: Event) -> None: - observer.stop() - - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_observer) - self.observer_active = True - return True - - def _get_monitor_observer(self) -> MonitorObserver | None: - """Get the monitor observer. - - This runs in the executor because the import - does blocking I/O. + Returns True if successful. """ - from pyudev import ( # pylint: disable=import-outside-toplevel - Context, - Monitor, - MonitorObserver, - ) - try: - context = Context() - except (ImportError, OSError): - return None + @hass_callback + def _usb_change_callback() -> None: + self._async_delayed_add_remove_scan() - monitor = Monitor.from_netlink(context) - try: - monitor.filter_by(subsystem="tty") - except ValueError as ex: # this fails on WSL - _LOGGER.debug( - "Unable to setup pyudev filtering; This is expected on WSL: %s", ex - ) - return None + watcher = AIOUSBWatcher() + watcher.async_register_callback(_usb_change_callback) + cancel = watcher.async_start() - observer = MonitorObserver( - monitor, callback=self._device_event, name="usb-observer" - ) + @hass_callback + def _async_stop_watcher(event: Event) -> None: + cancel() - observer.start() - return observer + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_watcher) - def _device_event(self, device: Device) -> None: - """Call when the observer receives a USB device event.""" - if device.action not in ("add", "remove"): - return - - _LOGGER.info( - "Received a udev device event %r for %s, triggering scan", - device.action, - device.device_node, - ) - - self.hass.create_task(self._async_scan()) + self.observer_active = True @hass_callback def async_register_scan_request_callback( @@ -466,11 +428,13 @@ class USBDiscovery: async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: """Process each discovered port.""" + _LOGGER.debug("Processing ports: %r", ports) usb_devices = { usb_device_from_port(port) for port in ports if port.vid is not None or port.pid is not None } + _LOGGER.debug("USB devices: %r", usb_devices) # CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and # `/dev/cu.SLAB_USBtoUART*`. The former does not work and we should ignore them. @@ -509,11 +473,27 @@ class USBDiscovery: for usb_device in usb_devices: await self._async_process_discovered_usb_device(usb_device) + @hass_callback + def _async_delayed_add_remove_scan(self) -> None: + """Request a serial scan after a debouncer delay.""" + if not self._add_remove_debouncer: + self._add_remove_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=ADD_REMOVE_SCAN_COOLDOWN, + immediate=False, + function=self._async_scan, + background=True, + ) + self._add_remove_debouncer.async_schedule_call() + async def _async_scan_serial(self) -> None: """Scan serial ports.""" - await self._async_process_ports( - await self.hass.async_add_executor_job(comports) - ) + _LOGGER.debug("Executing comports scan") + async with self._scan_lock: + await self._async_process_ports( + await self.hass.async_add_executor_job(comports) + ) if self.initial_scan_done: return diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 19269801c11..7035e2ab2cb 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["pyudev==0.24.1", "pyserial==3.5"] + "requirements": ["aiousbwatcher==1.1.1", "pyserial==3.5"] } diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index aa9817eab7d..3dd380e79a8 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -27,8 +27,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index aac31e085a0..e2b3411c193 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -11,8 +11,11 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, Platform from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import discovery, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + discovery, + entity_registry as er, +) from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 9c13aa1984a..cd65c42b22a 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -49,8 +49,7 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.start import async_at_started from homeassistant.helpers.template import is_number from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from homeassistant.util.enum import try_parse_enum from .const import ( diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index a6f0202ee25..0e09408551d 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -20,7 +20,7 @@ from homeassistant.components.camera import ( from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utc_from_timestamp diff --git a/homeassistant/components/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py index 82c00a57b5e..0ae03d9219e 100644 --- a/homeassistant/components/vacuum/device_action.py +++ b/homeassistant/components/vacuum/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 48f659103e1..424ffdc0ed2 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DELAY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 90938a6c1d2..69fc3d661e9 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -2,9 +2,9 @@ "config": { "step": { "user": { - "title": "Define the velbus connection type", + "title": "Define the Velbus connection type", "data": { - "name": "The name for this velbus connection", + "name": "The name for this Velbus connection", "port": "Connection string" } } @@ -31,21 +31,21 @@ "services": { "sync_clock": { "name": "Sync clock", - "description": "Syncs the velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", + "description": "Syncs the Velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", "fields": { "interface": { "name": "Interface", - "description": "The velbus interface to send the command to, this will be the same value as used during configuration." + "description": "The Velbus interface to send the command to, this will be the same value as used during configuration." }, "config_entry": { "name": "Config entry", - "description": "The config entry of the velbus integration" + "description": "The config entry of the Velbus integration" } } }, "scan": { "name": "Scan", - "description": "Scans the velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.", + "description": "Scans the Velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", @@ -59,7 +59,7 @@ }, "clear_cache": { "name": "Clear cache", - "description": "Clears the velbuscache and then starts a new scan.", + "description": "Clears the Velbus cache and then starts a new scan.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", @@ -101,7 +101,7 @@ "issues": { "deprecated_interface_parameter": { "title": "Deprecated 'interface' parameter", - "description": "The 'interface' parameter in the Velbus service calls is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + "description": "The 'interface' parameter in the Velbus actions is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." } } } diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py index fba023f7638..24f65aa3b0b 100644 --- a/homeassistant/components/velux/config_flow.py +++ b/homeassistant/components/velux/config_flow.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index c5323e1e9a8..50f6508e7ed 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -32,7 +32,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index e512676de9a..9b8ae42f620 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .common import ControllerData, get_controller_data from .entity import VeraEntity diff --git a/homeassistant/components/versasense/__init__.py b/homeassistant/components/versasense/__init__.py index ed4a8edf32c..cbd69ba0a81 100644 --- a/homeassistant/components/versasense/__init__.py +++ b/homeassistant/components/versasense/__init__.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 240a793f518..27e626faeac 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -21,6 +21,7 @@ from .const import ( from .coordinator import VeSyncDataCoordinator PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py new file mode 100644 index 00000000000..dd1b6398c06 --- /dev/null +++ b/homeassistant/components/vesync/binary_sensor.py @@ -0,0 +1,106 @@ +"""Binary Sensor for VeSync.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .common import rgetattr +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VeSyncBinarySensorEntityDescription(BinarySensorEntityDescription): + """A class that describes custom binary sensor entities.""" + + is_on: Callable[[VeSyncBaseDevice], bool] + + +SENSOR_DESCRIPTIONS: tuple[VeSyncBinarySensorEntityDescription, ...] = ( + VeSyncBinarySensorEntityDescription( + key="water_lacks", + translation_key="water_lacks", + is_on=lambda device: device.water_lacks, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + VeSyncBinarySensorEntityDescription( + key="details.water_tank_lifted", + translation_key="water_tank_lifted", + is_on=lambda device: device.details["water_tank_lifted"], + device_class=BinarySensorDeviceClass.PROBLEM, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary_sensor platform.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities(devices, async_add_entities, coordinator): + """Add entity.""" + async_add_entities( + ( + VeSyncBinarySensor(dev, description, coordinator) + for dev in devices + for description in SENSOR_DESCRIPTIONS + if rgetattr(dev, description.key) is not None + ), + ) + + +class VeSyncBinarySensor(BinarySensorEntity, VeSyncBaseEntity): + """Vesync binary sensor class.""" + + entity_description: VeSyncBinarySensorEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncBinarySensorEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + _LOGGER.debug(rgetattr(self.device, self.entity_description.key)) + return self.entity_description.is_on(self.device) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index c51b6a913d3..e2f4e1db2e4 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -12,6 +12,28 @@ from .const import VeSyncHumidifierDevice _LOGGER = logging.getLogger(__name__) +def rgetattr(obj: object, attr: str): + """Return a string in the form word.1.2.3 and return the item as 3. Note that this last value could be in a dict as well.""" + _this_func = rgetattr + sp = attr.split(".", 1) + if len(sp) == 1: + left, right = sp[0], "" + else: + left, right = sp + + if isinstance(obj, dict): + obj = obj.get(left) + elif hasattr(obj, left): + obj = getattr(obj, left) + else: + return None + + if right: + obj = _this_func(obj, right) + + return obj + + async def async_generate_device_list( hass: HomeAssistant, manager: VeSync ) -> list[VeSyncBaseDevice]: diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py index 6115cb9ee76..e19c46e5490 100644 --- a/homeassistant/components/vesync/config_flow.py +++ b/homeassistant/components/vesync/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 841185e4308..34454081567 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -48,6 +48,7 @@ DEV_TYPE_TO_HA = { "EverestAir": "fan", "Vital200S": "fan", "Vital100S": "fan", + "SmartTowerFan": "fan", "ESD16": "walldimmer", "ESWD16": "walldimmer", "ESL100": "bulb-dimmable", @@ -91,4 +92,9 @@ SKU_TO_BASE_DEVICE = { "LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-WEU": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-WUS": "EverestAir", # Alt ID Model EverestAir + "SmartTowerFan": "SmartTowerFan", + "LTF-F422S-KEU": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422S-WUSR": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422_WJP": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422S-WUS": "SmartTowerFan", # Alt ID Model SmartTowerFan } diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index ba1880f2492..21a92a22db2 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -36,6 +36,9 @@ FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" FAN_MODE_PET = "pet" FAN_MODE_TURBO = "turbo" +FAN_MODE_ADVANCED_SLEEP = "advancedSleep" +FAN_MODE_NORMAL = "normal" + PRESET_MODES = { "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], @@ -46,6 +49,12 @@ PRESET_MODES = { "EverestAir": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_TURBO], "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], "Vital100S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], + "SmartTowerFan": [ + FAN_MODE_ADVANCED_SLEEP, + FAN_MODE_AUTO, + FAN_MODE_TURBO, + FAN_MODE_NORMAL, + ], } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), @@ -56,6 +65,7 @@ SPEED_RANGE = { # off is not included "EverestAir": (1, 3), "Vital200S": (1, 4), "Vital100S": (1, 4), + "SmartTowerFan": (1, 13), } @@ -151,11 +161,6 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): return self.smartfan.mode return None - @property - def unique_info(self): - """Return the ID of this fan.""" - return self.smartfan.uuid - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the fan.""" @@ -212,10 +217,14 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): self.smartfan.auto_mode() elif preset_mode == FAN_MODE_SLEEP: self.smartfan.sleep_mode() + elif preset_mode == FAN_MODE_ADVANCED_SLEEP: + self.smartfan.advanced_sleep_mode() elif preset_mode == FAN_MODE_PET: self.smartfan.pet_mode() elif preset_mode == FAN_MODE_TURBO: self.smartfan.turbo_mode() + elif preset_mode == FAN_MODE_NORMAL: + self.smartfan.normal_mode() self.schedule_update_ha_state() diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 3d89d5dc6db..86e0d6b5d87 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -129,6 +129,11 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): """Return the available mist modes.""" return self._available_modes + @property + def current_humidity(self) -> int: + """Return the current humidity.""" + return self.device.humidity + @property def target_humidity(self) -> int: """Return the humidity we try to reach.""" @@ -137,7 +142,7 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): @property def mode(self) -> str | None: """Get the current preset mode.""" - return _get_ha_mode(self.device.mode) + return None if self.device.mode is None else _get_ha_mode(self.device.mode) def set_humidity(self, humidity: int) -> None: """Set the target humidity of the device.""" diff --git a/homeassistant/components/vesync/icons.json b/homeassistant/components/vesync/icons.json index e4769acc9a5..c11bd002049 100644 --- a/homeassistant/components/vesync/icons.json +++ b/homeassistant/components/vesync/icons.json @@ -7,6 +7,7 @@ "state": { "auto": "mdi:fan-auto", "sleep": "mdi:sleep", + "advanced_sleep": "mdi:sleep", "pet": "mdi:paw", "turbo": "mdi:weather-tornado" } diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index a23fe7936e7..3eb2a0c3fd5 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -43,6 +43,14 @@ "name": "Current voltage" } }, + "binary_sensor": { + "water_lacks": { + "name": "Low water" + }, + "water_tank_lifted": { + "name": "Water tank lifted" + } + }, "number": { "mist_level": { "name": "Mist level" @@ -55,6 +63,7 @@ "state": { "auto": "Auto", "sleep": "Sleep", + "advanced_sleep": "Advanced sleep", "pet": "Pet", "turbo": "Turbo" } diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index cb652270c69..4a75f5cccd2 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 62231a4e2fe..f62fdc363a6 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -32,8 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 36db8e92cc7..c1d4adda62a 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index fc18bdbd8da..190a893157c 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -13,9 +13,6 @@ from PyViCare.PyViCareUtils import ( PyViCareNotSupportedFeatureError, PyViCareRateLimitError, ) -from PyViCare.PyViCareVentilationDevice import ( - VentilationDevice as PyViCareVentilationDevice, -) from requests.exceptions import ConnectionError as RequestConnectionError from homeassistant.components.fan import FanEntity, FanEntityFeature @@ -50,6 +47,8 @@ class VentilationMode(enum.StrEnum): PERMANENT = "permanent" # on, speed controlled by program (levelOne-levelFour) VENTILATION = "ventilation" # activated by schedule + STANDBY = "standby" # activated by schedule + STANDARD = "standard" # activated by schedule SENSOR_DRIVEN = "sensor_driven" # activated by schedule, override by sensor SENSOR_OVERRIDE = "sensor_override" # activated by sensor @@ -77,6 +76,8 @@ class VentilationMode(enum.StrEnum): HA_TO_VICARE_MODE_VENTILATION = { VentilationMode.PERMANENT: "permanent", VentilationMode.VENTILATION: "ventilation", + VentilationMode.STANDBY: "standby", + VentilationMode.STANDARD: "standard", VentilationMode.SENSOR_DRIVEN: "sensorDriven", VentilationMode.SENSOR_OVERRIDE: "sensorOverride", } @@ -96,7 +97,7 @@ def _build_entities( return [ ViCareFan(get_device_serial(device.api), device.config, device.api) for device in device_list - if isinstance(device.api, PyViCareVentilationDevice) + if device.api.isVentilationDevice() ] @@ -118,7 +119,6 @@ class ViCareFan(ViCareEntity, FanEntity): """Representation of the ViCare ventilation device.""" _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) - _attr_supported_features = FanEntityFeature.SET_SPEED _attr_translation_key = "ventilation" def __init__( @@ -131,8 +131,8 @@ class ViCareFan(ViCareEntity, FanEntity): super().__init__( self._attr_translation_key, device_serial, device_config, device ) - # init presets - supported_modes = list[str](self._api.getAvailableModes()) + # init preset_mode + supported_modes = list[str](self._api.getVentilationModes()) self._attr_preset_modes = [ mode for mode in VentilationMode @@ -140,6 +140,12 @@ class ViCareFan(ViCareEntity, FanEntity): ] if len(self._attr_preset_modes) > 0: self._attr_supported_features |= FanEntityFeature.PRESET_MODE + # init set_speed + supported_levels: list[str] | None = None + with suppress(PyViCareNotSupportedFeatureError): + supported_levels = self._api.getVentilationLevels() + if supported_levels is not None and len(supported_levels) > 0: + self._attr_supported_features |= FanEntityFeature.SET_SPEED def update(self) -> None: """Update state of fan.""" @@ -147,7 +153,7 @@ class ViCareFan(ViCareEntity, FanEntity): try: with suppress(PyViCareNotSupportedFeatureError): self._attr_preset_mode = VentilationMode.from_vicare_mode( - self._api.getActiveMode() + self._api.getActiveVentilationMode() ) with suppress(PyViCareNotSupportedFeatureError): level = filter_state(self._api.getVentilationLevel()) @@ -203,10 +209,10 @@ class ViCareFan(ViCareEntity, FanEntity): level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) _LOGGER.debug("changing ventilation level to %s", level) - self._api.setPermanentLevel(level) + self._api.setVentilationLevel(level) def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" target_mode = VentilationMode.to_vicare_mode(preset_mode) _LOGGER.debug("changing ventilation mode to %s", target_mode) - self._api.setActiveMode(target_mode) + self._api.activateVentilationMode(target_mode) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 14624be2b6d..091deeba2a9 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -862,6 +862,27 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ), + ViCareSensorEntityDescription( + key="spf_total", + translation_key="spf_total", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorTotal(), + ), + ViCareSensorEntityDescription( + key="spf_dhw", + translation_key="spf_dhw", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorDHW(), + ), + ViCareSensorEntityDescription( + key="spf_heating", + translation_key="spf_heating", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorHeating(), + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 5ab92880ba0..26ca0f5a264 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -81,10 +81,12 @@ "state_attributes": { "preset_mode": { "state": { - "permanent": "permanent", - "ventilation": "schedule", - "sensor_driven": "sensor", - "sensor_override": "schedule with sensor-override" + "standby": "[%key:common::state::standby%]", + "permanent": "Permanent", + "ventilation": "Schedule", + "sensor_driven": "Sensor-driven", + "sensor_override": "Schedule with sensor-override", + "standard": "Minimal" } } } @@ -375,25 +377,25 @@ "name": "Energy export to grid" }, "photovoltaic_power_production_current": { - "name": "Solar power" + "name": "PV power" }, "photovoltaic_energy_production_today": { - "name": "Solar energy production today" + "name": "PV energy production today" }, "photovoltaic_energy_production_this_week": { - "name": "Solar energy production this week" + "name": "PV energy production this week" }, "photovoltaic_energy_production_this_month": { - "name": "Solar energy production this month" + "name": "PV energy production this month" }, "photovoltaic_energy_production_this_year": { - "name": "Solar energy production this year" + "name": "PV energy production this year" }, "photovoltaic_energy_production_total": { - "name": "Solar energy production total" + "name": "PV energy production total" }, "photovoltaic_status": { - "name": "Solar state", + "name": "PV state", "state": { "ready": "Standby", "production": "Producing" @@ -464,6 +466,15 @@ }, "heating_rod_hours": { "name": "Heating rod hours" + }, + "spf_total": { + "name": "Seasonal performance factor" + }, + "spf_dhw": { + "name": "Seasonal performance factor - domestic hot water" + }, + "spf_heating": { + "name": "Seasonal performance factor - heating" } }, "water_heater": { diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index 8451ae747de..fbfaf222cad 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -10,7 +10,7 @@ from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntityFeature, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType SERVICE_UPDATE_SETTING = "update_setting" diff --git a/homeassistant/components/vlc/media_player.py b/homeassistant/components/vlc/media_player.py index cd05c919d58..d1a481a99b1 100644 --- a/homeassistant/components/vlc/media_player.py +++ b/homeassistant/components/vlc/media_player.py @@ -20,10 +20,10 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index b95e987aef8..9597c706570 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import VlcConfigEntry from .const import DEFAULT_NAME, DOMAIN, LOGGER diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 9f1615ffa01..6bf42d86836 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -13,8 +13,8 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 0100435d6dc..1877b8c655c 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -8,23 +8,29 @@ from functools import partial import io import logging from pathlib import Path +import socket +import time from typing import TYPE_CHECKING, Any, Final import wave -from voip_utils import RtpDatagramProtocol +from voip_utils import SIP_PORT, RtpDatagramProtocol +from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint from homeassistant.components import tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( + AssistSatelliteAnnouncement, AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityDescription, + AssistSatelliteEntityFeature, ) +from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH +from .const import CHANNELS, CONF_SIP_PORT, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH from .devices import VoIPDevice from .entity import VoIPEntity @@ -34,6 +40,10 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) _PIPELINE_TIMEOUT_SEC: Final = 30 +_ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 +_ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 +_ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 +_ANNOUNCEMENT_RING_TIMEOUT: Final = 30 class Tones(IntFlag): @@ -80,6 +90,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None + _attr_supported_features = ( + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION + ) def __init__( self, @@ -105,6 +119,14 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._tones = tones self._processing_tone_done = asyncio.Event() + self._announcement: AssistSatelliteAnnouncement | None = None + self._announcement_future: asyncio.Future[Any] = asyncio.Future() + self._announcment_start_time: float = 0.0 + self._check_announcement_ended_task: asyncio.Task | None = None + self._last_chunk_time: float | None = None + self._rtp_port: int | None = None + self._run_pipeline_after_announce: bool = False + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -149,25 +171,146 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Set the current satellite configuration.""" raise NotImplementedError + async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: + """Announce media on the satellite. + + Plays announcement in a loop, blocking until the caller hangs up. + """ + await self._do_announce(announcement, run_pipeline_after=False) + + async def _do_announce( + self, announcement: AssistSatelliteAnnouncement, run_pipeline_after: bool + ) -> None: + """Announce media on the satellite. + + Optionally run a voice pipeline after the announcement has finished. + """ + self._announcement_future = asyncio.Future() + self._run_pipeline_after_announce = run_pipeline_after + + if self._rtp_port is None: + # Choose random port for RTP + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + sock.bind(("", 0)) + _rtp_ip, self._rtp_port = sock.getsockname() + sock.close() + + # HA SIP server + source_ip = await async_get_source_ip(self.hass) + sip_port = self.config_entry.options.get(CONF_SIP_PORT, SIP_PORT) + source_endpoint = get_sip_endpoint(host=source_ip, port=sip_port) + + try: + # VoIP ID is SIP header + destination_endpoint = SipEndpoint(self.voip_device.voip_id) + except ValueError: + # VoIP ID is IP address + destination_endpoint = get_sip_endpoint( + host=self.voip_device.voip_id, port=SIP_PORT + ) + + # Reset state so we can time out if needed + self._last_chunk_time = None + self._announcment_start_time = time.monotonic() + self._announcement = announcement + + # Make the call + sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol + call_info = sip_protocol.outgoing_call( + source=source_endpoint, + destination=destination_endpoint, + rtp_port=self._rtp_port, + ) + + # Check if caller hung up or didn't pick up + self._check_announcement_ended_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._check_announcement_ended(), + "voip_announcement_ended", + ) + ) + + try: + await self._announcement_future + except TimeoutError: + # Stop ringing + sip_protocol.cancel_call(call_info) + raise + + async def _check_announcement_ended(self) -> None: + """Continuously checks if an audio chunk was received within a time limit. + + If not, the caller is presumed to have hung up and the announcement is ended. + """ + while self._announcement is not None: + current_time = time.monotonic() + if (self._last_chunk_time is None) and ( + (current_time - self._announcment_start_time) + > _ANNOUNCEMENT_RING_TIMEOUT + ): + # Ring timeout + self._announcement = None + self._check_announcement_ended_task = None + self._announcement_future.set_exception( + TimeoutError("User did not pick up in time") + ) + _LOGGER.debug("Timed out waiting for the user to pick up the phone") + break + + if (self._last_chunk_time is not None) and ( + (current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC + ): + # Caller hung up + self._announcement = None + self._announcement_future.set_result(None) + self._check_announcement_ended_task = None + _LOGGER.debug("Announcement ended") + break + + await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2) + + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + await self._do_announce(start_announcement, run_pipeline_after=True) + # ------------------------------------------------------------------------- # VoIP # ------------------------------------------------------------------------- def on_chunk(self, audio_bytes: bytes) -> None: """Handle raw audio chunk.""" - if self._run_pipeline_task is None: - # Run pipeline until voice command finishes, then start over - self._clear_audio_queue() - self._tts_done.clear() + self._last_chunk_time = time.monotonic() + + if self._announcement is None: + # Pipeline with STT + if self._run_pipeline_task is None: + # Run pipeline until voice command finishes, then start over + self._clear_audio_queue() + self._tts_done.clear() + self._run_pipeline_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._run_pipeline(), + "voip_pipeline_run", + ) + ) + + self._audio_queue.put_nowait(audio_bytes) + elif self._run_pipeline_task is None: + # Announcement only + # Play announcement (will repeat) self._run_pipeline_task = self.config_entry.async_create_background_task( self.hass, - self._run_pipeline(), - "voip_pipeline_run", + self._play_announcement(self._announcement), + "voip_play_announcement", ) - self._audio_queue.put_nowait(audio_bytes) - async def _run_pipeline(self) -> None: + """Run a pipeline with STT input and TTS output.""" _LOGGER.debug("Starting pipeline") self.async_set_context(Context(user_id=self.config_entry.data["user"])) @@ -209,6 +352,31 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._run_pipeline_task = None _LOGGER.debug("Pipeline finished") + async def _play_announcement( + self, announcement: AssistSatelliteAnnouncement + ) -> None: + """Play an announcement once.""" + _LOGGER.debug("Playing announcement") + + try: + await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) + await self._send_tts(announcement.original_media_id, wait_for_tone=False) + + if not self._run_pipeline_after_announce: + # Delay before looping announcement + await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) + except Exception: + _LOGGER.exception("Unexpected error while playing announcement") + raise + finally: + self._run_pipeline_task = None + _LOGGER.debug("Announcement finished") + + if self._run_pipeline_after_announce: + # Clear announcement to allow pipeline to run + self._announcement = None + self._announcement_future.set_result(None) + def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" while not self._audio_queue.empty(): @@ -239,7 +407,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._pipeline_had_error = True _LOGGER.warning(event) - async def _send_tts(self, media_id: str) -> None: + async def _send_tts(self, media_id: str, wait_for_tone: bool = True) -> None: """Send TTS audio to caller via RTP.""" try: if self.transport is None: @@ -253,7 +421,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol if extension != "wav": raise ValueError(f"Only WAV audio can be streamed, got {extension}") - if (self._tones & Tones.PROCESSING) == Tones.PROCESSING: + if wait_for_tone and ((self._tones & Tones.PROCESSING) == Tones.PROCESSING): # Don't overlap TTS and processing beep _LOGGER.debug("Waiting for processing tone") await self._processing_tone_done.wait() diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index e96039a6b45..e3b2861dbe5 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -3,9 +3,9 @@ "name": "Voice over IP", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, - "dependencies": ["assist_pipeline", "assist_satellite"], + "dependencies": ["assist_pipeline", "assist_satellite", "network"], "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.3.0"] + "requirements": ["voip-utils==0.3.1"] } diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py index c4fa7b1088b..5bd4a63c923 100644 --- a/homeassistant/components/volkszaehler/sensor.py +++ b/homeassistant/components/volkszaehler/sensor.py @@ -26,8 +26,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/vultr/__init__.py b/homeassistant/components/vultr/__init__.py index 36f43cf0ac0..66527bf458e 100644 --- a/homeassistant/components/vultr/__init__.py +++ b/homeassistant/components/vultr/__init__.py @@ -9,7 +9,7 @@ from vultr import Vultr as VultrAPI from homeassistant.components import persistent_notification from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/vultr/binary_sensor.py b/homeassistant/components/vultr/binary_sensor.py index 6a697eebe11..3972de8a625 100644 --- a/homeassistant/components/vultr/binary_sensor.py +++ b/homeassistant/components/vultr/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py index 843aa416297..c392c382cbd 100644 --- a/homeassistant/components/vultr/sensor.py +++ b/homeassistant/components/vultr/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, UnitOfInformation from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vultr/switch.py b/homeassistant/components/vultr/switch.py index b03d613895a..0b1f2247684 100644 --- a/homeassistant/components/vultr/switch.py +++ b/homeassistant/components/vultr/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/w800rf32/__init__.py b/homeassistant/components/w800rf32/__init__.py index 62b9ba810d9..7dab0b137c5 100644 --- a/homeassistant/components/w800rf32/__init__.py +++ b/homeassistant/components/w800rf32/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index efd72c4564c..d68d950e641 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -9,7 +9,7 @@ import wakeonlan from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index fcf8936d498..16df34c1d1b 100644 --- a/homeassistant/components/wake_on_lan/switch.py +++ b/homeassistant/components/wake_on_lan/switch.py @@ -21,8 +21,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 3e1387cb714..c9155950680 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -25,7 +25,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.deprecation import deprecated_class +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp @@ -134,11 +139,11 @@ class WaterHeaterEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes water heater entities.""" -@deprecated_class("WaterHeaterEntityDescription", breaks_in_ha_version="2026.1") -class WaterHeaterEntityEntityDescription( - WaterHeaterEntityDescription, frozen_or_thawed=True -): - """A (deprecated) class that describes water heater entities.""" +_DEPRECATED_WaterHeaterEntityEntityDescription = DeprecatedConstant( + WaterHeaterEntityDescription, + "WaterHeaterEntityDescription", + breaks_in_ha_version="2026.1", +) CACHED_PROPERTIES_WITH_ATTR_ = { @@ -414,3 +419,11 @@ async def async_service_temperature_set( kwargs[value] = temp await entity.async_set_temperature(**kwargs) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index 49cfc7e9a07..d68919ff8f3 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -15,8 +15,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN diff --git a/homeassistant/components/watson_iot/__init__.py b/homeassistant/components/watson_iot/__init__.py index de8c85f5ff0..0130b53930b 100644 --- a/homeassistant/components/watson_iot/__init__.py +++ b/homeassistant/components/watson_iot/__init__.py @@ -23,8 +23,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index 373d17438c9..194e0905ff0 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -10,7 +10,7 @@ from homeassistant.components.tts import ( PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/weatherflow_cloud/strings.json b/homeassistant/components/weatherflow_cloud/strings.json index f707cbb0353..d22c62a030c 100644 --- a/homeassistant/components/weatherflow_cloud/strings.json +++ b/homeassistant/components/weatherflow_cloud/strings.json @@ -4,7 +4,7 @@ "user": { "description": "Set up a WeatherFlow Forecast Station", "data": { - "api_token": "Personal api token" + "api_token": "Personal API token" } }, "reauth_confirm": { diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index b4fd3008cd8..907123561f7 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index f1a8e163398..174e8025dd0 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.6.0"], + "requirements": ["aiowebostv==0.6.1"], "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 4b39841e29d..c8b871b3bf2 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine from contextlib import suppress from datetime import timedelta from functools import wraps @@ -23,7 +23,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.const import ATTR_COMMAND, ATTR_SUPPORTED_FEATURES -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -78,9 +78,24 @@ COMMAND_SCHEMA: VolDictType = { SOUND_OUTPUT_SCHEMA: VolDictType = {vol.Required(ATTR_SOUND_OUTPUT): cv.string} SERVICES = ( - (SERVICE_BUTTON, BUTTON_SCHEMA, "async_button"), - (SERVICE_COMMAND, COMMAND_SCHEMA, "async_command"), - (SERVICE_SELECT_SOUND_OUTPUT, SOUND_OUTPUT_SCHEMA, "async_select_sound_output"), + ( + SERVICE_BUTTON, + BUTTON_SCHEMA, + "async_button", + SupportsResponse.NONE, + ), + ( + SERVICE_COMMAND, + COMMAND_SCHEMA, + "async_command", + SupportsResponse.OPTIONAL, + ), + ( + SERVICE_SELECT_SOUND_OUTPUT, + SOUND_OUTPUT_SCHEMA, + "async_select_sound_output", + SupportsResponse.OPTIONAL, + ), ) @@ -92,19 +107,23 @@ async def async_setup_entry( """Set up the LG webOS TV platform.""" platform = entity_platform.async_get_current_platform() - for service_name, schema, method in SERVICES: - platform.async_register_entity_service(service_name, schema, method) + for service_name, schema, method, supports_response in SERVICES: + platform.async_register_entity_service( + service_name, schema, method, supports_response=supports_response + ) async_add_entities([LgWebOSMediaPlayerEntity(entry)]) -def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( - func: Callable[Concatenate[_T, _P], Awaitable[None]], -) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: +def cmd[_R, **_P]( + func: Callable[Concatenate[LgWebOSMediaPlayerEntity, _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[LgWebOSMediaPlayerEntity, _P], Coroutine[Any, Any, _R]]: """Catch command exceptions.""" @wraps(func) - async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + async def cmd_wrapper( + self: LgWebOSMediaPlayerEntity, *args: _P.args, **kwargs: _P.kwargs + ) -> _R: """Wrap all command methods.""" if self.state is MediaPlayerState.OFF: raise HomeAssistantError( @@ -116,7 +135,7 @@ def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( }, ) try: - await func(self, *args, **kwargs) + return await func(self, *args, **kwargs) except WEBOSTV_EXCEPTIONS as error: raise HomeAssistantError( translation_domain=DOMAIN, @@ -228,7 +247,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): if self.state != MediaPlayerState.OFF or not self._supported_features: supported = SUPPORT_WEBOSTV - if self._client.sound_output in ("external_arc", "external_speaker"): + if self._client.sound_output == "external_speaker": supported = supported | SUPPORT_WEBOSTV_VOLUME elif self._client.sound_output != "lineout": supported = ( @@ -376,9 +395,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): await self._client.set_mute(mute) @cmd - async def async_select_sound_output(self, sound_output: str) -> None: + async def async_select_sound_output(self, sound_output: str) -> ServiceResponse: """Select the sound output.""" - await self._client.change_sound_output(sound_output) + return await self._client.change_sound_output(sound_output) @cmd async def async_media_play_pause(self) -> None: @@ -481,9 +500,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): await self._client.button(button) @cmd - async def async_command(self, command: str, **kwargs: Any) -> None: + async def async_command(self, command: str, **kwargs: Any) -> ServiceResponse: """Send a command.""" - await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD)) + return await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD)) async def _async_fetch_image(self, url: str) -> tuple[bytes | None, str | None]: """Retrieve an image. diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 6068cd3ff0b..619e0952457 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import async_wemo_dispatcher_connect from .const import DOMAIN as WEMO_DOMAIN diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 64adcda4742..6231324bb0d 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -5,7 +5,7 @@ import logging from aiohttp import ClientError from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigEntry @@ -39,6 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> await auth.do_auth(store=False) except (ClientError, TimeoutError) as ex: raise ConfigEntryNotReady("Cannot connect") from ex + except WhirlpoolAccountLocked as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="account_locked" + ) from ex if not auth.is_access_token_valid(): _LOGGER.error("Authentication failed") diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 069a5ca1e4f..19715643e3a 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -9,13 +9,12 @@ from typing import Any from aiohttp import ClientError import voluptuous as vol from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_BRAND, CONF_BRANDS_MAP, CONF_REGIONS_MAP, DOMAIN @@ -40,31 +39,41 @@ REAUTH_SCHEMA = vol.Schema( ) -async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, str]: - """Validate the user input allows us to connect. +async def authenticate( + hass: HomeAssistant, data: dict[str, str], check_appliances_exist: bool +) -> str | None: + """Authenticate with the api. - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + Returns the error translation key if authentication fails, or None on success. """ session = async_get_clientsession(hass) region = CONF_REGIONS_MAP[data[CONF_REGION]] brand = CONF_BRANDS_MAP[data[CONF_BRAND]] backend_selector = BackendSelector(brand, region) auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD], session) + try: await auth.do_auth() - except (TimeoutError, ClientError) as exc: - raise CannotConnect from exc + except WhirlpoolAccountLocked: + return "account_locked" + except (TimeoutError, ClientError): + return "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + return "unknown" if not auth.is_access_token_valid(): - raise InvalidAuth + return "invalid_auth" - appliances_manager = AppliancesManager(backend_selector, auth, session) - await appliances_manager.fetch_appliances() + if check_appliances_exist: + appliances_manager = AppliancesManager(backend_selector, auth, session) + await appliances_manager.fetch_appliances() - if not appliances_manager.aircons and not appliances_manager.washer_dryers: - raise NoAppliances + if not appliances_manager.aircons and not appliances_manager.washer_dryers: + return "no_appliances" - return {"title": data[CONF_USERNAME]} + return None class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): @@ -90,14 +99,10 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): brand = user_input[CONF_BRAND] data = {**reauth_entry.data, CONF_PASSWORD: password, CONF_BRAND: brand} - try: - await validate_input(self.hass, data) - except InvalidAuth: - errors["base"] = "invalid_auth" - except (CannotConnect, TimeoutError): - errors["base"] = "cannot_connect" - else: + error_key = await authenticate(self.hass, data, False) + if not error_key: return self.async_update_reload_and_abort(reauth_entry, data=data) + errors["base"] = error_key return self.async_show_form( step_id="reauth_confirm", @@ -113,38 +118,17 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - errors = {} - - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except NoAppliances: - errors["base"] = "no_appliances" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + error_key = await authenticate(self.hass, user_input, True) + if not error_key: await self.async_set_unique_id( user_input[CONF_USERNAME].lower(), raise_on_progress=False ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + errors = {"base": error_key} return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class NoAppliances(HomeAssistantError): - """Error to indicate no supported appliances in the user account.""" diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index b463a1a76f8..67901eea482 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.11"] + "requirements": ["whirlpool-sixth-sense==0.18.12"] } diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 09257652ece..95df3fb9098 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -1,4 +1,7 @@ { + "common": { + "account_locked_error": "The account is locked. Please follow the instructions in the manufacturer's app to unlock it" + }, "config": { "step": { "user": { @@ -31,6 +34,7 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { + "account_locked": "[%key:component::whirlpool::common::account_locked_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%]", @@ -85,5 +89,10 @@ "name": "End time" } } + }, + "exceptions": { + "account_locked": { + "message": "[%key:component::whirlpool::common::account_locked_error%]" + } } } diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index a32e940073b..806e7abed00 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -10,7 +10,7 @@ from wirelesstagpy.exceptions import WirelessTagsException from homeassistant.components import persistent_notification from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 9e8075dd874..8a0957e16e3 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index 7a3cbe5efe2..9b92480ecf9 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index cae5d63988c..9fa630d4f55 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 691026ccb9a..856aeeffc5c 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from . import WithingsConfigEntry from .const import DOMAIN diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index acab0fa5c40..ac867fbfdca 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -10,8 +10,8 @@ from aiowithings import WithingsClient, WorkoutCategory from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from . import DOMAIN, WithingsConfigEntry from .coordinator import WithingsWorkoutDataUpdateCoordinator diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 3684208f102..3aad6d805d0 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index 89ea14bbbd0..88e5a317cdd 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_NAME, CONF_TIME_ZONE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_TIME_FORMAT, DOMAIN diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py index 45f39894abb..1a64954bb4a 100644 --- a/homeassistant/components/worldtidesinfo/sensor.py +++ b/homeassistant/components/worldtidesinfo/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index 50700b78f35..ed3312fc950 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -14,8 +14,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_HOST, CONF_PIN, CONF_TIMEOUT, PERCENTAGE from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index 73714b75c95..8ae93c809f2 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_NAME, CONF_API_KEY, CONF_ID, CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py index d98f1f51d54..fbdebe11657 100644 --- a/homeassistant/components/x10/light.py +++ b/homeassistant/components/x10/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 5282a34903a..ab0d510a709 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -6,6 +6,7 @@ import logging from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList +from xbox.webapi.common.signed_session import SignedSession from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -36,7 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth(session) + signed_session = await hass.async_add_executor_job(SignedSession) + auth = api.AsyncConfigEntryAuth(signed_session, session) client = XboxLiveClient(auth) consoles: SmartglassConsoleList = await client.smartglass.get_console_list() diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index d4c47e4cc39..9fa7c14b5c9 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -11,10 +11,12 @@ from homeassistant.util.dt import utc_from_timestamp class AsyncConfigEntryAuth(AuthenticationManager): """Provide xbox authentication tied to an OAuth2 based config entry.""" - def __init__(self, oauth_session: OAuth2Session) -> None: + def __init__( + self, signed_session: SignedSession, oauth_session: OAuth2Session + ) -> None: """Initialize xbox auth.""" # Leaving out client credentials as they are handled by Home Assistant - super().__init__(SignedSession(), "", "", "") + super().__init__(signed_session, "", "", "") self._oauth_session = oauth_session self.oauth = self._get_oauth_token() diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index 9d4a29d2c78..5968a17f418 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index b7f4aa1942e..579994aaf6b 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -17,8 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index c8057f1df4a..11ce7a0107b 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DOMAIN, GATEWAYS_KEY from .entity import XiaomiDevice diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index 1dfc5e53410..518003ceedb 100644 --- a/homeassistant/components/xiaomi_miio/device_tracker.py +++ b/homeassistant/components/xiaomi_miio/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index e1de3f56252..12ed9f7195b 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -33,7 +33,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 3f1f8b926b3..c1f778928d9 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -42,7 +42,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color as color_util, dt as dt_util diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 02f4d4e94e5..b4c4300dbe8 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -29,7 +29,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/xiaomi_tv/media_player.py b/homeassistant/components/xiaomi_tv/media_player.py index 675c802f79c..19cb4faf2b9 100644 --- a/homeassistant/components/xiaomi_tv/media_player.py +++ b/homeassistant/components/xiaomi_tv/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 3fb5dd166a1..968f925d1e8 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -35,8 +35,7 @@ from homeassistant.const import ( CONF_SENDER, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.template as template_helper +from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xs1/__init__.py b/homeassistant/components/xs1/__init__.py index 6f7197817d7..15fb9d021c6 100644 --- a/homeassistant/components/xs1/__init__.py +++ b/homeassistant/components/xs1/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py index b911c92ba0f..7fdad118cde 100644 --- a/homeassistant/components/yale/lock.py +++ b/homeassistant/components/yale/lock.py @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import YaleConfigEntry, YaleData from .entity import YaleEntity diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 3ceee367284..1aaad2aa63a 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_AREA_ID, diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index 95c4785a341..f87d29fffed 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -15,11 +15,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index 850afd05150..c7621eb639a 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -13,8 +13,8 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 9b71bbc3b16..0b3ceaf2aee 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, VolDictType from .const import ( diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 35892764bcb..15975ba22bd 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 8cc3f2600e5..92ee3976f7f 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -32,13 +32,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +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 homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import YEELIGHT_FLOW_TRANSITION_SCHEMA from .const import ( diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 6efb66449ab..cf7bc9c9035 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.14", "async-upnp-client==0.43.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.43.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yeelightsunflower/light.py b/homeassistant/components/yeelightsunflower/light.py index 0d8247fc865..4cacd1def22 100644 --- a/homeassistant/components/yeelightsunflower/light.py +++ b/homeassistant/components/yeelightsunflower/light.py @@ -17,10 +17,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/youless/entity.py b/homeassistant/components/youless/entity.py index 9931768c267..4500fe71a96 100644 --- a/homeassistant/components/youless/entity.py +++ b/homeassistant/components/youless/entity.py @@ -20,6 +20,6 @@ class YouLessEntity(CoordinatorEntity[YouLessCoordinator]): identifiers={(DOMAIN, device_group)}, manufacturer="YouLess", model=self.device.model, - name=device_name, + translation_key=device_name, sw_version=self.device.firmware_version, ) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 413f1ad6958..3afb215ed5f 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -36,7 +36,6 @@ class YouLessSensorEntityDescription(SensorEntityDescription): """Describes a YouLess sensor entity.""" device_group: str - device_group_name: str value_func: Callable[[YoulessAPI], float | None] @@ -44,9 +43,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="water", device_group="water", - device_group_name="Water meter", - name="Water usage", - icon="mdi:water", + translation_key="total_water", device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, @@ -57,9 +54,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="gas", device_group="gas", - device_group_name="Gas meter", - name="Gas usage", - icon="mdi:fire", + translation_key="total_gas_m3", device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, @@ -68,9 +63,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="usage", device_group="power", - device_group_name="Power usage", - name="Power Usage", - icon="mdi:meter-electric", + translation_key="active_power_w", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -83,9 +76,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="power_low", device_group="power", - device_group_name="Power usage", - name="Energy low", - icon="mdi:transmission-tower-export", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "1"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -96,9 +88,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="power_high", device_group="power", - device_group_name="Power usage", - name="Energy high", - icon="mdi:transmission-tower-export", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "2"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -109,9 +100,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="power_total", device_group="power", - device_group_name="Power usage", - name="Energy total", - icon="mdi:transmission-tower-export", + translation_key="total_energy_import_kwh", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -124,9 +113,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_1_power", device_group="power", - device_group_name="Power usage", - name="Phase 1 power", - icon=None, + translation_key="active_power_phase_w", + translation_placeholders={"phase": "1"}, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -135,9 +123,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_1_voltage", device_group="power", - device_group_name="Power usage", - name="Phase 1 voltage", - icon=None, + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "1"}, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -148,9 +135,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_1_current", device_group="power", - device_group_name="Power usage", - name="Phase 1 current", - icon=None, + translation_key="active_current_phase_a", + translation_placeholders={"phase": "1"}, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -161,9 +147,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_2_power", device_group="power", - device_group_name="Power usage", - name="Phase 2 power", - icon=None, + translation_key="active_power_phase_w", + translation_placeholders={"phase": "2"}, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -172,9 +157,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_2_voltage", device_group="power", - device_group_name="Power usage", - name="Phase 2 voltage", - icon=None, + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "2"}, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -185,9 +169,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_2_current", device_group="power", - device_group_name="Power usage", - name="Phase 2 current", - icon=None, + translation_key="active_current_phase_a", + translation_placeholders={"phase": "2"}, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -198,9 +181,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_3_power", device_group="power", - device_group_name="Power usage", - name="Phase 3 power", - icon=None, + translation_key="active_power_phase_w", + translation_placeholders={"phase": "3"}, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -209,9 +191,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_3_voltage", device_group="power", - device_group_name="Power usage", - name="Phase 3 voltage", - icon=None, + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "3"}, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -222,9 +203,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_3_current", device_group="power", - device_group_name="Power usage", - name="Phase 3 current", - icon=None, + translation_key="active_current_phase_a", + translation_placeholders={"phase": "3"}, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -235,9 +215,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="delivery_low", device_group="delivery", - device_group_name="Energy delivery", - name="Energy delivery low", - icon="mdi:transmission-tower-import", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "1"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -250,9 +229,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="delivery_high", device_group="delivery", - device_group_name="Energy delivery", - name="Energy delivery high", - icon="mdi:transmission-tower-import", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "2"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -265,9 +243,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="extra_total", device_group="extra", - device_group_name="Extra meter", - name="Extra total", - icon="mdi:meter-electric", + translation_key="total_s0_kwh", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -280,9 +256,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="extra_usage", device_group="extra", - device_group_name="Extra meter", - name="Extra usage", - icon="mdi:lightning-bolt", + translation_key="active_s0_w", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -316,6 +290,7 @@ class YouLessSensor(YouLessEntity, SensorEntity): """Representation of a Sensor.""" entity_description: YouLessSensorEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -327,7 +302,7 @@ class YouLessSensor(YouLessEntity, SensorEntity): super().__init__( coordinator, f"{device}_{description.device_group}", - description.device_group_name, + description.device_group, ) self._attr_unique_id = f"{DOMAIN}_{device}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index e0eddd7d137..8a3f6cb5d8b 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -14,5 +14,59 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "device": { + "water": { + "name": "Water meter" + }, + "gas": { + "name": "Gas meter" + }, + "power": { + "name": "Power meter" + }, + "delivery": { + "name": "Energy delivery meter" + }, + "extra": { + "name": "S0 meter" + } + }, + "entity": { + "sensor": { + "total_water": { + "name": "Total water usage" + }, + "total_gas_m3": { + "name": "Total gas usage" + }, + "active_power_w": { + "name": "Current power usage" + }, + "active_power_phase_w": { + "name": "Power phase {phase}" + }, + "active_voltage_phase_v": { + "name": "Voltage phase {phase}" + }, + "active_current_phase_a": { + "name": "Current phase {phase}" + }, + "total_energy_import_tariff_kwh": { + "name": "Energy import tariff {tariff}" + }, + "total_energy_import_kwh": { + "name": "Total energy import" + }, + "total_energy_export_tariff_kwh": { + "name": "Energy export tariff {tariff}" + }, + "total_s0_kwh": { + "name": "Total energy" + }, + "active_s0_w": { + "name": "Current usage" + } + } } } diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index 8460a105fcb..aee4b83508c 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -8,11 +8,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.device_registry as dr from .api import AsyncConfigEntryAuth from .const import AUTH, COORDINATOR, DOMAIN diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 05881d649cf..524bac271de 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -27,8 +27,11 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import event as event_helper, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + event as event_helper, + state as state_helper, +) from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index 7728233ebc0..27d7e71d8d9 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py index 69b7c63476a..2ab46820b56 100644 --- a/homeassistant/components/zengge/light.py +++ b/homeassistant/components/zengge/light.py @@ -18,10 +18,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 6fe2b5b1923..be6f2d111d7 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.141.0"] + "requirements": ["zeroconf==0.142.0"] } diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index ed6ed03ad27..36a964a46ab 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -20,7 +20,7 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 12831c96932..ec8850b187d 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 1897b741d87..28f029b62d5 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -21,8 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/zha/icons.json b/homeassistant/components/zha/icons.json index 6ba4aab18ab..d43e213aa4a 100644 --- a/homeassistant/components/zha/icons.json +++ b/homeassistant/components/zha/icons.json @@ -124,6 +124,12 @@ }, "on_led_color": { "default": "mdi:palette" + }, + "device_mode": { + "default": "mdi:cogs" + }, + "pilot_wire_mode": { + "default": "mdi:radiator" } }, "sensor": { diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py index 3de81e1255d..05539a063d2 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -10,7 +10,7 @@ from zha.application.const import ZHA_EVENT from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import DOMAIN as ZHA_DOMAIN from .helpers import async_get_zha_device_proxy diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index f9323fe99df..6a42bc986e9 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.45"], + "requirements": ["zha==0.0.47"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 670d6af3c52..0506496f447 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -46,9 +46,15 @@ _EXTRA_STATE_ATTRIBUTES: set[str] = { "rms_current_max_ph_b", "rms_current_max_ph_c", "rms_voltage_max", + "rms_voltage_max_ph_b", + "rms_voltage_max_ph_c", "ac_frequency_max", "power_factor_max", + "power_factor_max_ph_b", + "power_factor_max_ph_c", "active_power_max", + "active_power_max_ph_b", + "active_power_max_ph_c", # Smart Energy metering "device_type", "status", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 35c9f35887d..c73a0989faa 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -592,6 +592,24 @@ }, "window_detection": { "name": "Open window detection" + }, + "silence_alarm": { + "name": "Silence alarm" + }, + "preheat_active": { + "name": "Preheat active" + }, + "fault_alarm": { + "name": "Fault alarm" + }, + "led_indicator": { + "name": "LED indicator" + }, + "error_or_battery_low": { + "name": "Error or battery low" + }, + "flow_switch": { + "name": "Flow switch" } }, "button": { @@ -612,6 +630,9 @@ }, "restart_device": { "name": "Restart device" + }, + "frost_lock_reset": { + "name": "Frost lock reset" } }, "climate": { @@ -885,6 +906,144 @@ }, "fading_time": { "name": "Fading time" + }, + "temperature_offset": { + "name": "Temperature offset" + }, + "humidity_offset": { + "name": "Humidity offset" + }, + "comfort_temperature_min": { + "name": "Comfort temperature min" + }, + "comfort_temperature_max": { + "name": "Comfort temperature max" + }, + "comfort_humidity_min": { + "name": "Comfort humidity min" + }, + "comfort_humidity_max": { + "name": "Comfort humidity max" + }, + "measurement_interval": { + "name": "Measurement interval" + }, + "on_time": { + "name": "On time" + }, + "alarm_duration": { + "name": "Alarm duration" + }, + "max_set": { + "name": "Liquid max percentage" + }, + "mini_set": { + "name": "Liquid minimal percentage" + }, + "installation_height": { + "name": "Height from sensor to tank bottom" + }, + "liquid_depth_max": { + "name": "Height from sensor to liquid level" + }, + "interval_time": { + "name": "Interval time" + }, + "target_distance": { + "name": "Target distance" + }, + "hold_delay_time": { + "name": "Hold delay time" + }, + "breath_detection_max": { + "name": "Breath detection max" + }, + "breath_detection_min": { + "name": "Breath detection min" + }, + "small_move_detection_max": { + "name": "Small move detection max" + }, + "small_move_detection_min": { + "name": "Small move detection min" + }, + "small_move_sensitivity": { + "name": "Small move sensitivity" + }, + "breath_sensitivity": { + "name": "Breath sensitivity" + }, + "entry_sensitivity": { + "name": "Entry sensitivity" + }, + "entry_distance_indentation": { + "name": "Entry distance indentation" + }, + "illuminance_threshold": { + "name": "Illuminance threshold" + }, + "block_time": { + "name": "Block time" + }, + "motion_sensitivity": { + "name": "Motion sensitivity" + }, + "radar_sensitivity": { + "name": "Radar sensitivity" + }, + "motionless_detection": { + "name": "Motionless detection" + }, + "motionless_sensitivity": { + "name": "Motionless detection sensitivity" + }, + "output_time": { + "name": "Output time" + }, + "illuminance_interval": { + "name": "Illuminance interval" + }, + "temperature_report_interval": { + "name": "Temperature report interval" + }, + "humidity_report_interval": { + "name": "Humidity report interval" + }, + "alarm_temperature_max": { + "name": "Alarm temperature max" + }, + "alarm_temperature_min": { + "name": "Alarm temperature min" + }, + "temperature_sensitivity": { + "name": "Temperature sensitivity" + }, + "alarm_humidity_max": { + "name": "Alarm humidity max" + }, + "alarm_humidity_min": { + "name": "Alarm humidity min" + }, + "humidity_sensitivity": { + "name": "Humidity sensitivity" + }, + "deadzone_temperature": { + "name": "Deadzone temperature" + }, + "min_temperature": { + "name": "Min temperature" + }, + "max_temperature": { + "name": "Max temperature" + }, + "valve_countdown": { + "name": "Irrigation time" + }, + "quantitative_watering": { + "name": "Quantitative watering" + }, + "valve_duration": { + "name": "Irrigation duration" } }, "select": { @@ -1028,9 +1187,63 @@ }, "operation_mode": { "name": "Operation mode" + }, + "device_mode": { + "name": "Device mode" + }, + "pilot_wire_mode": { + "name": "Pilot wire mode" + }, + "alarm_ringtone": { + "name": "Alarm ringtone" + }, + "liquid_state": { + "name": "Liquid state" + }, + "breaker_mode": { + "name": "Breaker mode" + }, + "breaker_status": { + "name": "Breaker status" + }, + "status_indication": { + "name": "Status indication" + }, + "breaker_polarity": { + "name": "Breaker polarity" + }, + "work_mode": { + "name": "Work mode" + }, + "presence_sensitivity": { + "name": "Presence sensitivity" + }, + "fading_time": { + "name": "Fading time" + }, + "display_unit": { + "name": "Display unit" + }, + "alarm_mode": { + "name": "Alarm mode" + }, + "alarm_volume": { + "name": "Alarm volume" + }, + "working_day": { + "name": "Working day" + }, + "eco_mode": { + "name": "Eco mode" } }, "sensor": { + "active_power_ph_b": { + "name": "Power phase B" + }, + "active_power_ph_c": { + "name": "Power phase C" + }, "analog_input": { "name": "Analog input" }, @@ -1046,12 +1259,24 @@ "instantaneous_demand": { "name": "Instantaneous demand" }, + "power_factor_ph_b": { + "name": "Power factor phase B" + }, + "power_factor_ph_c": { + "name": "Power factor phase C" + }, "rms_current_ph_b": { "name": "Current phase B" }, "rms_current_ph_c": { "name": "Current phase C" }, + "rms_voltage_ph_b": { + "name": "Voltage phase B" + }, + "rms_voltage_ph_c": { + "name": "Voltage phase C" + }, "summation_delivered": { "name": "Summation delivered" }, @@ -1252,6 +1477,90 @@ }, "self_test": { "name": "Self test result" + }, + "voc_index": { + "name": "VOC index" + }, + "energy_ph_a": { + "name": "Energy phase A" + }, + "energy_ph_b": { + "name": "Energy phase B" + }, + "energy_ph_c": { + "name": "Energy phase C" + }, + "energy_produced": { + "name": "Energy produced" + }, + "energy_produced_ph_a": { + "name": "Energy produced phase A" + }, + "energy_produced_ph_b": { + "name": "Energy produced phase B" + }, + "energy_produced_ph_c": { + "name": "Energy produced phase C" + }, + "total_power_factor": { + "name": "Total power factor" + }, + "self_test_result": { + "name": "Self test result" + }, + "lower_explosive_limit": { + "name": "% Lower explosive limit" + }, + "liquid_depth": { + "name": "Liquid depth" + }, + "liquid_level_percent": { + "name": "Liquid level ratio" + }, + "target_distance": { + "name": "Target distance" + }, + "human_motion_state": { + "name": "Human motion state" + }, + "temperature_alarm": { + "name": "Temperature alarm" + }, + "humidity_alarm": { + "name": "Humidity alarm" + }, + "alarm_state": { + "name": "Alarm state" + }, + "power_type": { + "name": "Power type" + }, + "valve_position": { + "name": "Valve position" + }, + "time_left": { + "name": "Time left" + }, + "valve_status": { + "name": "Valve status" + }, + "valve_duration": { + "name": "Irrigation duration" + }, + "smart_irrigation": { + "name": "Smart irrigation" + }, + "surplus_flow": { + "name": "Surplus flow" + }, + "single_watering_duration": { + "name": "Single watering duration" + }, + "single_watering_amount": { + "name": "Single watering amount" + }, + "error_status": { + "name": "Error status" } }, "switch": { @@ -1380,6 +1689,63 @@ }, "find_switch": { "name": "Distance switch" + }, + "display_enabled": { + "name": "Display enabled" + }, + "show_smiley": { + "name": "Show smiley" + }, + "on_only_when_dark": { + "name": "On only when dark" + }, + "mute_siren": { + "name": "Mute siren" + }, + "self_test_switch": { + "name": "Self test" + }, + "output_switch": { + "name": "Output switch" + }, + "siren_on": { + "name": "Siren on" + }, + "enable_tamper_alarm": { + "name": "Enable tamper alarm" + }, + "temperature_alarm": { + "name": "Temperature alarm" + }, + "humidity_alarm": { + "name": "Humidity alarm" + }, + "silence_alarm": { + "name": "Silence alarm" + }, + "frost_protection": { + "name": "Frost protection" + }, + "factory_reset": { + "name": "Factory reset" + }, + "away_mode": { + "name": "Away mode" + }, + "schedule_enable": { + "name": "Schedule enable" + }, + "scale_protection": { + "name": "Scale protection" + }, + "frost_lock": { + "name": "Frost lock" + }, + "switch_enabled": { + "name": "Switch enabled" + }, + "total_flow_reset_switch": { + "name": "Total flow reset switch" } } } diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 5ffd7117d93..d562a807a4f 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -59,8 +59,7 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import VolDictType, VolSchemaType diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index b5acc230472..af3287d3068 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/ziggo_mediabox_xl/media_player.py b/homeassistant/components/ziggo_mediabox_xl/media_player.py index 6e858b454e9..fe180208801 100644 --- a/homeassistant/components/ziggo_mediabox_xl/media_player.py +++ b/homeassistant/components/ziggo_mediabox_xl/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index e87a2b1531d..c2e57b0448b 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 75769d9fd98..4f79f8876e5 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index 23adf2f4c88..13da0927196 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 1a1cd6ae9c1..37ce9a51c91 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -70,9 +70,8 @@ from homeassistant.components.websocket_api import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from .config_validation import BITMASK_SCHEMA diff --git a/homeassistant/components/zwave_js/config_validation.py b/homeassistant/components/zwave_js/config_validation.py index 30bc2f16789..2615bfc72b3 100644 --- a/homeassistant/components/zwave_js/config_validation.py +++ b/homeassistant/components/zwave_js/config_validation.py @@ -4,7 +4,7 @@ from typing import Any import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv # Validates that a bitmask is provided in hex form and converts it to decimal # int equivalent since that's what the library uses diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 639d2fbcd7a..0a2ca95a2b0 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -42,7 +42,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo diff --git a/homeassistant/components/zwave_js/logbook.py b/homeassistant/components/zwave_js/logbook.py index 315793b9726..120084788e1 100644 --- a/homeassistant/components/zwave_js/logbook.py +++ b/homeassistant/components/zwave_js/logbook.py @@ -9,7 +9,7 @@ from zwave_js_server.const import CommandClass from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import ( ATTR_COMMAND_CLASS, diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index fe293fd178b..8389eff8cb2 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -29,8 +29,11 @@ from zwave_js_server.util.node import ( from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.group import expand_entity_ids diff --git a/homeassistant/const.py b/homeassistant/const.py index 699aebcafdf..bdce303e64a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 2 +MINOR_VERSION: Final = 3 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 6b3028826dc..08fe28e4df5 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -9,6 +9,7 @@ APPLICATION_CREDENTIALS = [ "geocaching", "google", "google_assistant_sdk", + "google_drive", "google_mail", "google_photos", "google_sheets", @@ -24,6 +25,7 @@ APPLICATION_CREDENTIALS = [ "neato", "nest", "netatmo", + "onedrive", "point", "senz", "spotify", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 03ebbac6191..f7297087976 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -6,6 +6,7 @@ To update, run python3 -m script.hassfest FLOWS = { "helper": [ "derivative", + "filter", "generic_hygrostat", "generic_thermostat", "group", @@ -230,6 +231,7 @@ FLOWS = { "google", "google_assistant_sdk", "google_cloud", + "google_drive", "google_generative_ai_conversation", "google_mail", "google_photos", @@ -434,6 +436,7 @@ FLOWS = { "omnilogic", "oncue", "ondilo_ico", + "onedrive", "onewire", "onkyo", "onvif", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 7d14ab0f444..b9d51ac1006 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -61,6 +61,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "axis-e82725*", "macaddress": "E82725*", }, + { + "domain": "balboa", + "registered_devices": True, + }, + { + "domain": "balboa", + "macaddress": "001527*", + }, { "domain": "blink", "hostname": "blink*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e22a85557d8..0c081a0232b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2295,6 +2295,12 @@ "iot_class": "cloud_push", "name": "Google Cloud" }, + "google_drive": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Drive" + }, "google_generative_ai_conversation": { "integration_type": "service", "config_flow": true, @@ -2860,7 +2866,7 @@ "iot_class": "local_polling" }, "incomfort": { - "name": "Intergas InComfort/Intouch Lan2RF gateway", + "name": "Intergas gateway", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" @@ -3802,6 +3808,12 @@ "iot_class": "cloud_push", "name": "Microsoft Teams" }, + "onedrive": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "OneDrive" + }, "xbox": { "integration_type": "hub", "config_flow": true, @@ -7430,7 +7442,7 @@ }, "filter": { "integration_type": "helper", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "generic_hygrostat": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 203f01e7d68..be15d88aec2 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -522,6 +522,11 @@ ZEROCONF = { "domain": "homekit", }, ], + "_homewizard._tcp.local.": [ + { + "domain": "homewizard", + }, + ], "_hscp._tcp.local.": [ { "domain": "apple_tv", diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index a8e8fa4160d..0841585e1a1 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -29,7 +29,7 @@ from homeassistant.requirements import ( async_clear_install_history, async_get_integration_with_requirements, ) -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import loader as yaml_loader from . import config_validation as cv from .typing import ConfigType diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 695af80bc1c..fa2dd42589b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -56,8 +56,8 @@ from homeassistant.exceptions import ( TemplateError, ) from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as dt_util from . import config_validation as cv, entity_registry as er from .sun import get_astral_event_date diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 2c8dbe69c22..4978158c0f6 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -107,8 +107,11 @@ from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.generated import currencies from homeassistant.generated.countries import COUNTRIES from homeassistant.generated.languages import LANGUAGES -from homeassistant.util import raise_if_invalid_path, slugify as util_slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import ( + dt as dt_util, + raise_if_invalid_path, + slugify as util_slugify, +) from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 685509cb29d..975b4a2aec9 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -24,11 +24,11 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_suggest_report_issue +from homeassistant.util import uuid as uuid_util from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data -import homeassistant.util.uuid as uuid_util from . import storage, translation from .debounce import Debouncer diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 0d7614c569c..c8cc6979226 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -426,11 +426,12 @@ class EntityPlatform: type(exc).__name__, ) return False - except Exception: + except Exception as exc: logger.exception( - "Error while setting up %s platform for %s", + "Error while setting up %s platform for %s: %s", self.platform_name, self.domain, + exc, # noqa: TRY401 ) return False else: diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index 109d363d262..1a1373e19ef 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -12,8 +12,8 @@ from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.const import __version__ as ha_version from homeassistant.core import HomeAssistant, callback +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as dt_util from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index fd1f84a85ff..78812061a03 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -10,7 +10,7 @@ from typing import Any, Self, cast from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import json_loads diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 255739c0059..4873d935537 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]: # pylint: disable-next=import-outside-toplevel from homeassistant.components import ( alarm_control_panel, + assist_satellite, calendar, camera, climate, @@ -108,6 +109,7 @@ def _base_components() -> dict[str, ModuleType]: return { "alarm_control_panel": alarm_control_panel, + "assist_satellite": assist_satellite, "calendar": calendar, "camera": camera, "climate": climate, diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index ac1fe3bb29d..fe94be68763 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -30,8 +30,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from homeassistant.util import json as json_util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, json as json_util from homeassistant.util.file import WriteError from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index d191d474480..ef11028515a 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -10,7 +10,7 @@ from functools import wraps from typing import Any from homeassistant.core import ServiceResponse -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .typing import TemplateVarsType diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2959e8bf322..891d91e134b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,11 +3,12 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 -aiohasupervisor==0.2.2b5 -aiohttp-asyncmdnsresolver==0.0.1 +aiohasupervisor==0.2.2b6 +aiohttp-asyncmdnsresolver==0.0.2 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 +aiousbwatcher==1.1.1 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 @@ -32,12 +33,12 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.12.0 +habluetooth==3.14.0 hass-nabucasa==0.88.1 -hassil==2.1.0 +hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250109.2 -home-assistant-intents==2025.1.1 +home-assistant-frontend==20250130.0 +home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 @@ -57,7 +58,6 @@ pyserial==3.5 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.7.5 -pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.141.0 +zeroconf==0.142.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 568e8c84a30..a24568e9a6f 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -23,8 +23,7 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.check_config import async_check_ha_config_file -from homeassistant.util.yaml import Secrets -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import Secrets, loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs diff --git a/homeassistant/scripts/ensure_config.py b/homeassistant/scripts/ensure_config.py index e1ae7bc9142..1d568ec68b0 100644 --- a/homeassistant/scripts/ensure_config.py +++ b/homeassistant/scripts/ensure_config.py @@ -4,7 +4,7 @@ import argparse import asyncio import os -import homeassistant.config as config_util +from homeassistant import config as config_util from homeassistant.core import HomeAssistant # mypy: allow-untyped-calls, allow-untyped-defs diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 331389da7c6..1fa93a80cd5 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -425,8 +425,8 @@ async def _async_setup_component( ) return False # pylint: disable-next=broad-except - except (asyncio.CancelledError, SystemExit, Exception): - _LOGGER.exception("Error during setup of component %s", domain) + except (asyncio.CancelledError, SystemExit, Exception) as exc: + _LOGGER.exception("Error during setup of component %s: %s", domain, exc) # noqa: TRY401 async_notify_setup_error(hass, domain, integration.documentation) return False finally: diff --git a/mypy.ini b/mypy.ini index 4bcb3c596a2..28c214f1eaa 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1926,6 +1926,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_drive.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.google_photos.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2026,6 +2036,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.heos.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.here_travel_time.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3346,6 +3366,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.onedrive.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.onewire.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 0e67a78954b..74e3d51a222 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0.dev0" +version = "2025.3.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -27,11 +27,11 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.2b5", + "aiohasupervisor==0.2.2b6", "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", - "aiohttp-asyncmdnsresolver==0.0.1", + "aiohttp-asyncmdnsresolver==0.0.2", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", @@ -82,7 +82,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.141.0" + "zeroconf==0.142.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 2ffb530393e..77fd3887db4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,11 +4,11 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 -aiohttp-asyncmdnsresolver==0.0.1 +aiohttp-asyncmdnsresolver==0.0.2 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.141.0 +zeroconf==0.142.0 diff --git a/requirements_all.txt b/requirements_all.txt index e7e1b767fe4..02091e9ec2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.30 +AIOSomecomfort==0.0.32 # homeassistant.components.adax Adax-local==0.1.5 @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==28.0.1 +aioesphomeapi==29.0.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -261,7 +261,10 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.2.2b6 + +# homeassistant.components.home_connect +aiohomeconnect==0.12.1 # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -403,6 +406,9 @@ aiotractive==0.6.0 # homeassistant.components.unifi aiounifi==81 +# homeassistant.components.usb +aiousbwatcher==1.1.1 + # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -416,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.0 +aiowebostv==0.6.1 # homeassistant.components.withings aiowithings==3.1.5 @@ -511,7 +517,7 @@ async-upnp-client==0.43.0 asyncarve==0.1.1 # homeassistant.components.keyboard_remote -asyncinotify==4.0.2 +asyncinotify==4.2.0 # homeassistant.components.supla asyncpysupla==0.0.5 @@ -591,7 +597,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.1.1 +bleak-esphome==2.2.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 @@ -644,7 +650,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.1 +bring-api==1.0.0 # homeassistant.components.broadlink broadlink==0.19.0 @@ -744,7 +750,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==11.0.0 +deebot-client==11.1.0b1 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -1030,7 +1036,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 @@ -1097,7 +1103,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.12.0 +habluetooth==3.14.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 @@ -1106,7 +1112,7 @@ hass-nabucasa==0.88.1 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.1.0 +hassil==2.2.0 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -1140,16 +1146,13 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.2 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.1 - -# homeassistant.components.home_connect -homeconnect==0.8.0 +home-assistant-intents==2025.1.28 # homeassistant.components.homematicip_cloud -homematicip==1.1.6 +homematicip==1.1.7 # homeassistant.components.horizon horimote==0.4.1 @@ -1269,7 +1272,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.18.164225 +knx-frontend==2025.1.28.225404 # homeassistant.components.konnected konnected==1.2.0 @@ -1431,6 +1434,9 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 +# homeassistant.components.onedrive +msgraph-sdk==1.16.0 + # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1489,7 +1495,7 @@ nhc==0.3.9 nibe==2.14.0 # homeassistant.components.nice_go -nice-go==1.0.0 +nice-go==1.0.1 # homeassistant.components.nilu niluclient==0.1.2 @@ -1541,7 +1547,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.6 +ohme==1.2.8 # homeassistant.components.ollama ollama==0.4.7 @@ -1622,7 +1628,7 @@ pdunehd==1.3.2 peblar==0.4.0 # homeassistant.components.peco -peco==0.0.30 +peco==0.1.2 # homeassistant.components.pencom pencompy==0.0.3 @@ -2017,7 +2023,7 @@ pyiqvia==2022.04.0 pyirishrail==0.0.2 # homeassistant.components.iskra -pyiskra==0.1.14 +pyiskra==0.1.15 # homeassistant.components.iss pyiss==1.0.1 @@ -2304,7 +2310,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.6 +pysmlight==0.2.0 # homeassistant.components.snmp pysnmp==6.2.6 @@ -2378,11 +2384,14 @@ python-gc100==1.0.3a0 # homeassistant.components.gitlab_ci python-gitlab==1.6.0 +# homeassistant.components.google_drive +python-google-drive-api==0.0.2 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.1.1 +python-homewizard-energy==v8.3.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 @@ -2440,7 +2449,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.8.4 +python-roborock==2.9.7 # homeassistant.components.smarttub python-smarttub==0.0.38 @@ -2491,9 +2500,6 @@ pytrafikverket==1.1.1 # homeassistant.components.v2c pytrydan==0.8.0 -# homeassistant.components.usb -pyudev==0.24.1 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2558,7 +2564,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.2.59 # homeassistant.components.qbus -qbusmqttapi==1.2.3 +qbusmqttapi==1.2.4 # homeassistant.components.qingping qingping-ble==0.10.0 @@ -2985,7 +2991,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volkszaehler volkszaehler==0.4.0 @@ -3037,7 +3043,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.1.15 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.11 +whirlpool-sixth-sense==0.18.12 # homeassistant.components.whois whois==0.9.27 @@ -3064,7 +3070,7 @@ xbox-webapi==2.1.0 xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.4.0 +xknx==3.5.0 # homeassistant.components.knx xknxproject==3.8.1 @@ -3092,7 +3098,7 @@ yalexs-ble==2.5.6 yalexs==8.10.0 # homeassistant.components.yeelight -yeelight==0.7.14 +yeelight==0.7.16 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 @@ -3119,13 +3125,13 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.45 +zha==0.0.47 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d7a55f1a60..11905283d4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.30 +AIOSomecomfort==0.0.32 # homeassistant.components.adax Adax-local==0.1.5 @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==28.0.1 +aioesphomeapi==29.0.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -246,7 +246,10 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.2.2b6 + +# homeassistant.components.home_connect +aiohomeconnect==0.12.1 # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -385,6 +388,9 @@ aiotractive==0.6.0 # homeassistant.components.unifi aiounifi==81 +# homeassistant.components.usb +aiousbwatcher==1.1.1 + # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -398,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.0 +aiowebostv==0.6.1 # homeassistant.components.withings aiowithings==3.1.5 @@ -522,7 +528,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.1.1 +bleak-esphome==2.2.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 @@ -564,7 +570,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.1 +bring-api==1.0.0 # homeassistant.components.broadlink broadlink==0.19.0 @@ -634,7 +640,7 @@ dbus-fast==2.30.2 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==11.0.0 +deebot-client==11.1.0b1 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -880,7 +886,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 @@ -938,13 +944,13 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.12.0 +habluetooth==3.14.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 # homeassistant.components.conversation -hassil==2.1.0 +hassil==2.2.0 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -969,16 +975,13 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.2 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.1 - -# homeassistant.components.home_connect -homeconnect==0.8.0 +home-assistant-intents==2025.1.28 # homeassistant.components.homematicip_cloud -homematicip==1.1.6 +homematicip==1.1.7 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -1071,7 +1074,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.18.164225 +knx-frontend==2025.1.28.225404 # homeassistant.components.konnected konnected==1.2.0 @@ -1203,6 +1206,9 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 +# homeassistant.components.onedrive +msgraph-sdk==1.16.0 + # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1252,7 +1258,7 @@ nhc==0.3.9 nibe==2.14.0 # homeassistant.components.nice_go -nice-go==1.0.0 +nice-go==1.0.1 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 @@ -1289,7 +1295,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.6 +ohme==1.2.8 # homeassistant.components.ollama ollama==0.4.7 @@ -1349,7 +1355,7 @@ pdunehd==1.3.2 peblar==0.4.0 # homeassistant.components.peco -peco==0.0.30 +peco==0.1.2 # homeassistant.components.escea pescea==1.0.12 @@ -1640,7 +1646,7 @@ pyipp==0.17.0 pyiqvia==2022.04.0 # homeassistant.components.iskra -pyiskra==0.1.14 +pyiskra==0.1.15 # homeassistant.components.iss pyiss==1.0.1 @@ -1876,7 +1882,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.6 +pysmlight==0.2.0 # homeassistant.components.snmp pysnmp==6.2.6 @@ -1923,11 +1929,14 @@ python-fullykiosk==0.0.14 # homeassistant.components.sms # python-gammu==3.2.4 +# homeassistant.components.google_drive +python-google-drive-api==0.0.2 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.1.1 +python-homewizard-energy==v8.3.0 # homeassistant.components.izone python-izone==1.2.9 @@ -1973,7 +1982,7 @@ python-picnic-api==1.1.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.8.4 +python-roborock==2.9.7 # homeassistant.components.smarttub python-smarttub==0.0.38 @@ -2015,9 +2024,6 @@ pytrafikverket==1.1.1 # homeassistant.components.v2c pytrydan==0.8.0 -# homeassistant.components.usb -pyudev==0.24.1 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2070,7 +2076,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.2.59 # homeassistant.components.qbus -qbusmqttapi==1.2.3 +qbusmqttapi==1.2.4 # homeassistant.components.qingping qingping-ble==0.10.0 @@ -2401,7 +2407,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volvooncall volvooncall==0.10.3 @@ -2441,7 +2447,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.1.15 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.11 +whirlpool-sixth-sense==0.18.12 # homeassistant.components.whois whois==0.9.27 @@ -2465,7 +2471,7 @@ xbox-webapi==2.1.0 xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.4.0 +xknx==3.5.0 # homeassistant.components.knx xknxproject==3.8.1 @@ -2490,7 +2496,7 @@ yalexs-ble==2.5.6 yalexs==8.10.0 # homeassistant.components.yeelight -yeelight==0.7.14 +yeelight==0.7.16 # homeassistant.components.yolink yolink-api==0.4.7 @@ -2508,13 +2514,13 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.45 +zha==0.0.47 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 21b98d30f1e..2c433ba362e 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.1.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 706a482523a..a1ad52e6aa8 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1671,7 +1671,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "linux_battery", "lirc", "litejet", - "litterrobot", "livisi", "llamalab_automate", "local_calendar", @@ -1873,7 +1872,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "pioneer", "pjlink", "plaato", - "plugwise", "plant", "plex", "plum_lightpad", @@ -2131,7 +2129,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "torque", "touchline", "touchline_sl", - "tplink", "tplink_lte", "tplink_omada", "traccar", diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index d0f577c8068..d4fca62e98b 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from homeassistant.components.weather import ATTR_CONDITION_SNOWY from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .util import async_init_integration diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 17a301ccdf1..3efacb80560 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index abce262fd12..6363304effc 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -11,10 +11,9 @@ from aiohttp.test_utils import TestClient import pytest import voluptuous as vol -from homeassistant import const +from homeassistant import const, core as ha from homeassistant.auth.models import Credentials from homeassistant.bootstrap import DATA_LOGGING -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 917a9b654d5..5f06172404b 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -710,7 +710,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called Are', + 'speech': 'Sorry, I am not aware of any area called Are the', }), }), }), @@ -756,7 +756,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called Are', + 'speech': 'Sorry, I am not aware of any area called Are the', }), }), }), diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index d75cbd072e0..0cc0e94e149 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -40,6 +40,8 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: class MockAssistSatellite(AssistSatelliteEntity): """Mock Assist Satellite Entity.""" + _attr_tts_options = {"test-option": "test-value"} + def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None: """Initialize the mock entity.""" self._attr_unique_id = ulid_hex() @@ -67,6 +69,7 @@ class MockAssistSatellite(AssistSatelliteEntity): active_wake_words=["1234"], max_active_wake_words=1, ) + self.start_conversations = [] def on_pipeline_event(self, event: PipelineEvent) -> None: """Handle pipeline events.""" @@ -87,11 +90,21 @@ class MockAssistSatellite(AssistSatelliteEntity): """Set the current satellite configuration.""" self.config = config + async def async_start_conversation( + self, start_announcement: AssistSatelliteConfiguration + ) -> None: + """Start a conversation from the satellite.""" + self.start_conversations.append((self._extra_system_prompt, start_announcement)) + @pytest.fixture def entity() -> MockAssistSatellite: """Mock Assist Satellite Entity.""" - return MockAssistSatellite("Test Entity", AssistSatelliteEntityFeature.ANNOUNCE) + return MockAssistSatellite( + "Test Entity", + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION, + ) @pytest.fixture diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index c3464beac97..46facb80844 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -25,11 +25,24 @@ from homeassistant.components.assist_satellite.entity import AssistSatelliteStat from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import ENTITY_ID from .conftest import MockAssistSatellite +@pytest.fixture(autouse=True) +async def set_pipeline_tts(hass: HomeAssistant, init_components: ConfigEntry) -> None: + """Set up a pipeline with a TTS engine.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + tts_engine="tts.mock_entity", + tts_language="en", + tts_voice="test-voice", + ) + + async def test_entity_state( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: @@ -64,7 +77,7 @@ async def test_entity_state( assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None assert kwargs["device_id"] is entity.device_entry.id - assert kwargs["tts_audio_output"] is None + assert kwargs["tts_audio_output"] == {"test-option": "test-value"} assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( silence_seconds=vad.VadSensitivity.to_seconds(vad.VadSensitivity.DEFAULT) @@ -200,24 +213,12 @@ async def test_announce( expected_params: tuple[str, str], ) -> None: """Test announcing on a device.""" - await async_update_pipeline( - hass, - async_get_pipeline(hass), - tts_engine="tts.mock_entity", - tts_language="en", - tts_voice="test-voice", - ) - - entity._attr_tts_options = {"test-option": "test-value"} - original_announce = entity.async_announce - announce_started = asyncio.Event() async def async_announce(announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING await original_announce(announcement) - announce_started.set() def tts_generate_media_source_id( hass: HomeAssistant, @@ -475,3 +476,104 @@ async def test_vad_sensitivity_entity_not_found( with pytest.raises(RuntimeError): await entity.async_accept_pipeline_from_satellite(audio_stream) + + +@pytest.mark.parametrize( + ("service_data", "expected_params"), + [ + ( + { + "start_message": "Hello", + "extra_system_prompt": "Better system prompt", + }, + ( + "Better system prompt", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://generated", + media_id_source="tts", + ), + ), + ), + ( + { + "start_message": "Hello", + "start_media_id": "media-source://given", + }, + ( + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + {"start_media_id": "http://example.com/given.mp3"}, + ( + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + original_media_id="http://example.com/given.mp3", + media_id_source="url", + ), + ), + ), + ], +) +async def test_start_conversation( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + service_data: dict, + expected_params: tuple[str, str], +) -> None: + """Test starting a conversation on a device.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + conversation_engine="conversation.some_llm", + ) + + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://generated", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + service_data, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + assert entity.start_conversations[0] == expected_params + + +async def test_start_conversation_reject_builtin_agent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, +) -> None: + """Test starting a conversation on a device.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + {"start_message": "Hey!"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 4ae300ae56b..bcdd4d55330 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_august_with_devices, diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index eb177a35cfb..065ffef91ff 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_august_with_devices, diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 6466e5e7f22..243e132dae2 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -51,8 +51,7 @@ from homeassistant.helpers.script import ( _async_stop_scripts_at_shutdown, ) from homeassistant.setup import async_setup_component -from homeassistant.util import yaml as yaml_util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 4f456cc6d72..a7888dbd08c 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Coroutine +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from pathlib import Path from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch @@ -52,10 +52,17 @@ TEST_BACKUP_DEF456 = AgentBackup( protected=False, size=1, ) +TEST_BACKUP_PATH_DEF456 = Path("custom_def456.tar") TEST_DOMAIN = "test" +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i + + class BackupAgentTest(BackupAgent): """Test backup agent.""" @@ -64,6 +71,7 @@ class BackupAgentTest(BackupAgent): def __init__(self, name: str, backups: list[AgentBackup] | None = None) -> None: """Initialize the backup agent.""" self.name = name + self.unique_id = name if backups is None: backups = [ AgentBackup( @@ -161,7 +169,13 @@ async def setup_backup_integration( if with_hassio and agent_id == LOCAL_AGENT_ID: continue agent = hass.data[DATA_MANAGER].backup_agents[agent_id] - agent._backups = {backups.backup_id: backups for backups in agent_backups} + + async def open_stream() -> AsyncIterator[bytes]: + """Open a stream.""" + return aiter_from_iter((b"backup data",)) + + for backup in agent_backups: + await agent.async_upload_backup(open_stream=open_stream, backup=backup) if agent_id == LOCAL_AGENT_ID: agent._loaded_backups = True diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index 7831efeff9a..d0d9ac7e0e1 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -13,7 +13,7 @@ from homeassistant.components.backup import DOMAIN from homeassistant.components.backup.manager import NewBackup, WrittenBackup from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_PATH_ABC123 +from .common import TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456 from tests.common import get_fixture_path @@ -38,10 +38,14 @@ def mocked_tarfile_fixture() -> Generator[Mock]: @pytest.fixture(name="path_glob") -def path_glob_fixture() -> Generator[MagicMock]: +def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]: """Mock path glob.""" with patch( - "pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123] + "pathlib.Path.glob", + return_value=[ + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_ABC123, + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_DEF456, + ], ) as path_glob: yield path_glob @@ -72,6 +76,7 @@ def mock_create_backup() -> Generator[AsyncMock]: """Mock manager create backup.""" mock_written_backup = MagicMock(spec_set=WrittenBackup) mock_written_backup.backup.backup_id = "abc123" + mock_written_backup.backup.protected = False mock_written_backup.open_stream = AsyncMock() mock_written_backup.release_stream = AsyncMock() fut: Future[MagicMock] = Future() diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted new file mode 100644 index 00000000000..c97533fc1af Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted differ diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index f91473e3b70..68b00632a6b 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_delete_backup[found_backups0-True-1] +# name: test_delete_backup[found_backups0-abc123-1-unlink_path0] dict({ 'id': 1, 'result': dict({ @@ -10,7 +10,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups1-False-0] +# name: test_delete_backup[found_backups1-def456-1-unlink_path1] dict({ 'id': 1, 'result': dict({ @@ -21,7 +21,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups2-True-0] +# name: test_delete_backup[found_backups2-abc123-0-None] dict({ 'id': 1, 'result': dict({ @@ -32,13 +32,14 @@ 'type': 'result', }) # --- -# name: test_load_backups[None] +# name: test_load_backups[mock_read_backup] dict({ 'id': 1, 'result': dict({ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -46,7 +47,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None].1 +# name: test_load_backups[mock_read_backup].1 dict({ 'id': 2, 'result': dict({ @@ -61,9 +62,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -76,15 +80,38 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), + dict({ + 'addons': list([ + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 1, + }), + }), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'with_automatic_settings': None, + }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -97,6 +124,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -114,8 +142,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -128,6 +158,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -145,8 +176,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -159,6 +192,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -176,8 +210,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -190,6 +226,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -207,8 +244,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 45af91645ad..2fd81d6841a 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -11,6 +11,8 @@ }), ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -37,7 +39,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -53,6 +55,8 @@ }), ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -80,7 +84,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -96,6 +100,11 @@ }), ]), 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -122,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -138,6 +147,11 @@ }), ]), 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -165,7 +179,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 43b4c1260dd..08c19906241 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -17,9 +17,11 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), dict({ - 'agent_id': 'domain.test', + 'agent_id': 'test.test', + 'name': 'test', }), ]), }), @@ -232,6 +234,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -267,6 +271,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -314,6 +320,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -350,6 +358,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -386,6 +396,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -423,6 +435,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -459,6 +473,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -492,11 +508,59 @@ 'type': 'result', }) # --- -# name: test_config_update[command0] +# name: test_config_info[storage_data7] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -527,11 +591,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command0].1 +# name: test_config_update[commands0].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -563,12 +629,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command0].2 +# name: test_config_update[commands0].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -596,15 +664,17 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- -# name: test_config_update[command10] +# name: test_config_update[commands10] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -635,11 +705,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command10].1 +# name: test_config_update[commands10].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -671,12 +743,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command10].2 +# name: test_config_update[commands10].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -704,15 +778,17 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- -# name: test_config_update[command11] +# name: test_config_update[commands11] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -743,11 +819,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command11].1 +# name: test_config_update[commands11].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -779,12 +857,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command11].2 +# name: test_config_update[commands11].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -812,15 +892,17 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- -# name: test_config_update[command1] +# name: test_config_update[commands12] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -851,11 +933,304 @@ 'type': 'result', }) # --- -# name: test_config_update[command1].1 +# name: test_config_update[commands12].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands12].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 3, + 'version': 1, + }) +# --- +# name: test_config_update[commands13] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].2 + dict({ + 'id': 5, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + }), + 'test-agent2': dict({ + 'protected': True, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].3 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + }), + 'test-agent2': dict({ + 'protected': True, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 3, + 'version': 1, + }) +# --- +# name: test_config_update[commands1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands1].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -887,12 +1262,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command1].2 +# name: test_config_update[commands1].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -920,15 +1297,17 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- -# name: test_config_update[command2] +# name: test_config_update[commands2] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -959,11 +1338,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command2].1 +# name: test_config_update[commands2].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -996,12 +1377,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command2].2 +# name: test_config_update[commands2].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1030,15 +1413,17 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- -# name: test_config_update[command3] +# name: test_config_update[commands3] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1069,11 +1454,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command3].1 +# name: test_config_update[commands3].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1105,12 +1492,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command3].2 +# name: test_config_update[commands3].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1138,15 +1527,17 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- -# name: test_config_update[command4] +# name: test_config_update[commands4] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1177,11 +1568,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command4].1 +# name: test_config_update[commands4].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1215,12 +1608,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command4].2 +# name: test_config_update[commands4].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1250,15 +1645,17 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- -# name: test_config_update[command5] +# name: test_config_update[commands5] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1289,11 +1686,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command5].1 +# name: test_config_update[commands5].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1329,12 +1728,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command5].2 +# name: test_config_update[commands5].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1366,15 +1767,17 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- -# name: test_config_update[command6] +# name: test_config_update[commands6] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1405,11 +1808,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command6].1 +# name: test_config_update[commands6].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1441,12 +1846,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command6].2 +# name: test_config_update[commands6].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1474,15 +1881,17 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- -# name: test_config_update[command7] +# name: test_config_update[commands7] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1513,11 +1922,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command7].1 +# name: test_config_update[commands7].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1549,12 +1960,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command7].2 +# name: test_config_update[commands7].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1582,15 +1995,17 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- -# name: test_config_update[command8] +# name: test_config_update[commands8] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1621,11 +2036,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command8].1 +# name: test_config_update[commands8].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1657,12 +2074,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command8].2 +# name: test_config_update[commands8].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1690,15 +2109,17 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- -# name: test_config_update[command9] +# name: test_config_update[commands9] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1729,11 +2150,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command9].1 +# name: test_config_update[commands9].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1765,12 +2188,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command9].2 +# name: test_config_update[commands9].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1798,7 +2223,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1807,6 +2232,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1842,6 +2269,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1877,6 +2306,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1912,6 +2343,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1947,6 +2380,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1982,6 +2417,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2017,6 +2454,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2052,6 +2491,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2087,6 +2528,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2122,6 +2565,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2157,6 +2602,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2192,6 +2639,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2227,6 +2676,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2262,6 +2713,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2297,6 +2750,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2332,6 +2787,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2367,6 +2824,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2402,6 +2861,82 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command9].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2442,8 +2977,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2470,8 +3007,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2492,9 +3031,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2507,15 +3049,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2542,8 +3084,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2564,9 +3108,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2579,15 +3126,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2619,9 +3166,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2634,15 +3184,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2658,9 +3208,12 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -2673,15 +3226,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2708,9 +3261,12 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -2723,15 +3279,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2752,10 +3308,16 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2768,15 +3330,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2808,9 +3370,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2823,15 +3388,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2864,9 +3429,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -2879,15 +3447,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2920,9 +3488,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -2936,15 +3507,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2976,9 +3547,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -2991,15 +3565,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3031,9 +3605,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -3046,15 +3623,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3086,9 +3663,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -3101,15 +3681,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3141,9 +3721,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -3157,15 +3740,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3197,9 +3780,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3212,8 +3798,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3235,9 +3819,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3250,8 +3837,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3285,10 +3870,16 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3301,8 +3892,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3325,9 +3914,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3340,8 +3932,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3381,6 +3971,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -3402,6 +3993,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -3413,6 +4005,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -3424,6 +4017,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -3452,6 +4046,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -3473,6 +4068,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -3484,6 +4080,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -3495,6 +4092,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -3523,6 +4121,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -3544,6 +4143,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -3555,6 +4155,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -3566,6 +4167,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -3588,9 +4190,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3603,15 +4208,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3632,9 +4237,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3647,15 +4255,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3676,10 +4284,16 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3692,15 +4306,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3716,9 +4330,12 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -3731,8 +4348,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), dict({ @@ -3743,9 +4358,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3758,15 +4376,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3788,9 +4406,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3803,15 +4424,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3910,6 +4531,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 02252ef6fa5..ce34c51c105 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -12,21 +12,35 @@ from unittest.mock import MagicMock, mock_open, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup import DOMAIN +from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123 +from .common import ( + TEST_BACKUP_ABC123, + TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.fixture(name="read_backup") def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: """Mock read backup.""" with patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ) as read_backup: yield read_backup @@ -34,7 +48,7 @@ def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: @pytest.mark.parametrize( "side_effect", [ - None, + mock_read_backup, OSError("Boom"), TarError("Boom"), json.JSONDecodeError("Boom", "test", 1), @@ -94,11 +108,21 @@ async def test_upload( @pytest.mark.usefixtures("read_backup") @pytest.mark.parametrize( - ("found_backups", "backup_exists", "unlink_calls"), + ("found_backups", "backup_id", "unlink_calls", "unlink_path"), [ - ([TEST_BACKUP_PATH_ABC123], True, 1), - ([TEST_BACKUP_PATH_ABC123], False, 0), - (([], True, 0)), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_ABC123.backup_id, + 1, + TEST_BACKUP_PATH_ABC123, + ), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_DEF456.backup_id, + 1, + TEST_BACKUP_PATH_DEF456, + ), + (([], TEST_BACKUP_ABC123.backup_id, 0, None)), ], ) async def test_delete_backup( @@ -108,8 +132,9 @@ async def test_delete_backup( snapshot: SnapshotAssertion, path_glob: MagicMock, found_backups: list[Path], - backup_exists: bool, + backup_id: str, unlink_calls: int, + unlink_path: Path | None, ) -> None: """Test delete backup.""" assert await async_setup_component(hass, DOMAIN, {}) @@ -118,12 +143,13 @@ async def test_delete_backup( path_glob.return_value = found_backups with ( - patch("pathlib.Path.exists", return_value=backup_exists), - patch("pathlib.Path.unlink") as unlink, + patch("pathlib.Path.unlink", autospec=True) as unlink, ): await client.send_json_auto_id( - {"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id} + {"type": "backup/delete", "backup_id": backup_id} ) assert await client.receive_json() == snapshot assert unlink.call_count == unlink_calls + for call in unlink.mock_calls: + assert call.args[0] == unlink_path diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index b7b86cc1d45..aac39c04d31 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,7 +1,7 @@ """Tests for the Backup integration.""" import asyncio -from collections.abc import AsyncIterator, Iterable +from collections.abc import AsyncIterator from io import BytesIO, StringIO import json import tarfile @@ -15,7 +15,12 @@ from homeassistant.components.backup import AddonInfo, AgentBackup, Folder from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration +from .common import ( + TEST_BACKUP_ABC123, + BackupAgentTest, + aiter_from_iter, + setup_backup_integration, +) from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator @@ -35,6 +40,9 @@ async def test_downloading_local_backup( "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", return_value=TEST_BACKUP_ABC123, ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), patch("pathlib.Path.exists", return_value=True), patch( "homeassistant.components.backup.http.FileResponse", @@ -73,9 +81,14 @@ async def test_downloading_local_encrypted_backup_file_not_found( await setup_backup_integration(hass) client = await hass_client() - with patch( - "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", - return_value=TEST_BACKUP_ABC123, + with ( + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), ): resp = await client.get( "/api/backup/download/abc123?agent_id=backup.local&password=blah" @@ -93,12 +106,6 @@ async def test_downloading_local_encrypted_backup( await _test_downloading_encrypted_backup(hass_client, "backup.local") -async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: - """Convert an iterable to an async iterator.""" - for i in iterable: - yield i - - @patch.object(BackupAgentTest, "async_download_backup") async def test_downloading_remote_encrypted_backup( download_mock, @@ -233,12 +240,14 @@ async def test_uploading_a_backup_file( with patch( "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value=TEST_BACKUP_ABC123.backup_id, ) as async_receive_backup_mock: resp = await client.post( "/api/backup/upload?agent_id=backup.local", data={"file": StringIO("test")}, ) assert resp.status == 201 + assert await resp.json() == {"backup_id": TEST_BACKUP_ABC123.backup_id} assert async_receive_backup_mock.called diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 48e6db4ae9a..4a8d2360d3f 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -54,6 +54,8 @@ from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, BackupAgentTest, setup_backup_platform, ) @@ -89,14 +91,23 @@ def generate_backup_id_fixture() -> Generator[MagicMock]: yield mock +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.mark.usefixtures("mock_backup_generation") -async def test_async_create_backup( +async def test_create_backup_service( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mocked_json_bytes: Mock, mocked_tarfile: Mock, ) -> None: - """Test create backup.""" + """Test create backup service.""" assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -137,6 +148,128 @@ async def test_async_create_backup( ) +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("manager_kwargs", "expected_writer_kwargs"), + [ + ( + { + "agent_ids": ["backup.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + "with_automatic_settings": True, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Automatic backup 2025.1.0", + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ], +) +async def test_async_create_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, + manager_kwargs: dict[str, Any], + expected_writer_kwargs: dict[str, Any], +) -> None: + """Test create backup.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + + new_backup = NewBackup(backup_job_id="time-123") + backup_task = AsyncMock( + return_value=WrittenBackup( + backup=TEST_BACKUP_ABC123, + open_stream=AsyncMock(), + release_stream=AsyncMock(), + ), + )() # call it so that it can be awaited + + with patch( + "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup", + return_value=(new_backup, backup_task), + ) as create_backup: + await manager.async_create_backup(**manager_kwargs) + + assert create_backup.called + assert create_backup.call_args == call(**expected_writer_kwargs) + + @pytest.mark.usefixtures("mock_backup_generation") async def test_create_backup_when_busy( hass: HomeAssistant, @@ -275,8 +408,10 @@ async def test_initiate_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -297,6 +432,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -311,6 +447,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -318,6 +455,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -325,6 +463,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.COMPLETED, } @@ -361,11 +500,13 @@ async def test_initiate_backup( result = await ws_client.receive_json() backup_data = result["result"]["backup"] - backup_agent_ids = backup_data.pop("agent_ids") - assert backup_agent_ids == agent_ids assert backup_data == { "addons": [], + "agents": { + agent_id: {"protected": bool(password), "size": ANY} + for agent_id in agent_ids + }, "backup_id": backup_id, "database_included": include_database, "date": ANY, @@ -374,8 +515,6 @@ async def test_initiate_backup( "homeassistant_included": True, "homeassistant_version": "2025.1.0", "name": name, - "protected": bool(password), - "size": ANY, "with_automatic_settings": False, } @@ -417,9 +556,7 @@ async def test_initiate_backup_with_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup1", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -431,15 +568,11 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, { "addons": [], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 1}}, "backup_id": "backup2", "database_included": False, "date": "1980-01-01T00:00:00.000Z", @@ -451,8 +584,6 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test 2", - "protected": False, - "size": 1, "with_automatic_settings": None, }, { @@ -463,9 +594,7 @@ async def test_initiate_backup_with_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup3", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -477,8 +606,6 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, ] @@ -512,8 +639,10 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id( @@ -548,6 +677,7 @@ async def test_initiate_backup_with_agent_error( assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, "stage": None, + "reason": None, "state": CreateBackupState.IN_PROGRESS, } result = await ws_client.receive_json() @@ -561,6 +691,7 @@ async def test_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -568,6 +699,7 @@ async def test_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -575,6 +707,7 @@ async def test_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -584,7 +717,7 @@ async def test_initiate_backup_with_agent_error( new_expected_backup_data = { "addons": [], - "agent_ids": ["backup.local"], + "agents": {"backup.local": {"protected": False, "size": 123}}, "backup_id": "abc123", "database_included": True, "date": ANY, @@ -593,8 +726,6 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2025.1.0", "name": "Custom backup 2025.1.0", - "protected": False, - "size": 123, "with_automatic_settings": False, } @@ -608,8 +739,15 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "create_backup", + "reason": "upload_failed", + "stage": None, + "state": "failed", + }, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await hass.async_block_till_done() @@ -770,7 +908,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: { (DOMAIN, "automatic_backup_failed"): { "translation_key": "automatic_backup_failed_upload_agents", - "translation_placeholders": {"failed_agents": "test.remote"}, + "translation_placeholders": {"failed_agents": "remote"}, } }, ), @@ -877,8 +1015,10 @@ async def test_initiate_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -903,6 +1043,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -917,6 +1058,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -924,6 +1066,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -931,6 +1074,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -989,8 +1133,10 @@ async def test_initiate_backup_with_task_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1009,6 +1155,7 @@ async def test_initiate_backup_with_task_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -1016,6 +1163,7 @@ async def test_initiate_backup_with_task_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1095,8 +1243,10 @@ async def test_initiate_backup_file_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1123,6 +1273,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -1137,6 +1288,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -1144,6 +1296,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -1151,6 +1304,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1168,7 +1322,11 @@ class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): """Local backup agent.""" def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" + """Return the local path to an existing backup.""" + return Path("test.tar") + + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" return Path("test.tar") @@ -1203,8 +1361,8 @@ async def test_loading_platform_with_listener( await ws_client.send_json_auto_id({"type": "backup/agents/info"}) resp = await ws_client.receive_json() assert resp["result"]["agents"] == [ - {"agent_id": "backup.local"}, - {"agent_id": "test.remote1"}, + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.remote1", "name": "remote1"}, ] assert len(manager.local_backup_agents) == num_local_agents @@ -1220,8 +1378,8 @@ async def test_loading_platform_with_listener( await ws_client.send_json_auto_id({"type": "backup/agents/info"}) resp = await ws_client.receive_json() assert resp["result"]["agents"] == [ - {"agent_id": "backup.local"}, - {"agent_id": "test.remote2"}, + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.remote2", "name": "remote2"}, ] assert len(manager.local_backup_agents) == num_local_agents @@ -1437,6 +1595,7 @@ async def test_receive_backup_busy_manager( result = await ws_client.receive_json() assert result["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1492,9 +1651,7 @@ async def test_receive_backup_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup1", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -1506,15 +1663,11 @@ async def test_receive_backup_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, { "addons": [], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 1}}, "backup_id": "backup2", "database_included": False, "date": "1980-01-01T00:00:00.000Z", @@ -1526,8 +1679,6 @@ async def test_receive_backup_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test 2", - "protected": False, - "size": 1, "with_automatic_settings": None, }, { @@ -1538,9 +1689,7 @@ async def test_receive_backup_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup3", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -1552,8 +1701,6 @@ async def test_receive_backup_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, ] @@ -1588,8 +1735,10 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id( @@ -1630,6 +1779,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1637,6 +1787,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1644,6 +1795,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1651,6 +1803,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.COMPLETED, } @@ -1667,8 +1820,15 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "receive_backup", + "reason": None, + "stage": None, + "state": "completed", + }, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await hass.async_block_till_done() @@ -1729,8 +1889,10 @@ async def test_receive_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1763,6 +1925,7 @@ async def test_receive_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1770,6 +1933,7 @@ async def test_receive_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1777,6 +1941,7 @@ async def test_receive_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1851,8 +2016,10 @@ async def test_receive_backup_file_write_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1871,10 +2038,6 @@ async def test_receive_backup_file_write_error( with ( patch("pathlib.Path.open", open_mock), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_BACKUP_ABC123, - ), ): resp = await client.post( "/api/backup/upload?agent_id=test.remote", @@ -1885,6 +2048,7 @@ async def test_receive_backup_file_write_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1892,6 +2056,7 @@ async def test_receive_backup_file_write_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1899,6 +2064,7 @@ async def test_receive_backup_file_write_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": "unknown_error", "stage": None, "state": ReceiveBackupState.FAILED, } @@ -1961,8 +2127,10 @@ async def test_receive_backup_read_tar_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1992,6 +2160,7 @@ async def test_receive_backup_read_tar_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1999,6 +2168,7 @@ async def test_receive_backup_read_tar_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2006,6 +2176,7 @@ async def test_receive_backup_read_tar_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": "unknown_error", "stage": None, "state": ReceiveBackupState.FAILED, } @@ -2029,6 +2200,7 @@ async def test_receive_backup_read_tar_error( "unlink_call_count", "unlink_exception", "final_state", + "final_state_reason", "response_status", ), [ @@ -2042,6 +2214,7 @@ async def test_receive_backup_read_tar_error( 1, None, ReceiveBackupState.COMPLETED, + None, 201, ), ( @@ -2054,6 +2227,7 @@ async def test_receive_backup_read_tar_error( 1, None, ReceiveBackupState.COMPLETED, + None, 201, ), ( @@ -2066,6 +2240,7 @@ async def test_receive_backup_read_tar_error( 1, None, ReceiveBackupState.COMPLETED, + None, 201, ), ( @@ -2078,6 +2253,7 @@ async def test_receive_backup_read_tar_error( 1, OSError("Boom!"), ReceiveBackupState.FAILED, + "unknown_error", 500, ), ], @@ -2096,6 +2272,7 @@ async def test_receive_backup_file_read_error( unlink_call_count: int, unlink_exception: Exception | None, final_state: ReceiveBackupState, + final_state_reason: str | None, response_status: int, ) -> None: """Test file read error during backup receive.""" @@ -2130,8 +2307,10 @@ async def test_receive_backup_file_read_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -2166,6 +2345,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2173,6 +2353,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2180,6 +2361,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2187,6 +2369,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": final_state_reason, "stage": None, "state": final_state, } @@ -2203,18 +2386,61 @@ async def test_receive_backup_file_read_error( @pytest.mark.usefixtures("path_glob") @pytest.mark.parametrize( - ("agent_id", "password_param", "restore_database", "restore_homeassistant", "dir"), + ( + "agent_id", + "backup_id", + "password_param", + "backup_path", + "restore_database", + "restore_homeassistant", + "dir", + ), [ - (LOCAL_AGENT_ID, {}, True, False, "backups"), - (LOCAL_AGENT_ID, {"password": "abc123"}, False, True, "backups"), - ("test.remote", {}, True, True, "tmp_backups"), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_DEF456.backup_id, + {}, + TEST_BACKUP_PATH_DEF456, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {"password": "abc123"}, + TEST_BACKUP_PATH_ABC123, + False, + True, + "backups", + ), + ( + "test.remote", + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + True, + "tmp_backups", + ), ], ) async def test_restore_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, agent_id: str, + backup_id: str, password_param: dict[str, str], + backup_path: Path, restore_database: bool, restore_homeassistant: bool, dir: str, @@ -2254,14 +2480,14 @@ async def test_restore_backup( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) await ws_client.send_json_auto_id( { "type": "backup/restore", - "backup_id": TEST_BACKUP_ABC123.backup_id, + "backup_id": backup_id, "agent_id": agent_id, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, @@ -2272,6 +2498,7 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2279,6 +2506,7 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.CORE_RESTART, } @@ -2288,6 +2516,7 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.COMPLETED, } @@ -2298,17 +2527,17 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["success"] is True - backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + full_backup_path = f"{hass.config.path()}/{dir}/{backup_path.name}" expected_restore_file = json.dumps( { - "path": backup_path, + "path": full_backup_path, "password": password, "remove_after_restore": agent_id != LOCAL_AGENT_ID, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, } ) - validate_password_mock.assert_called_once_with(Path(backup_path), password) + validate_password_mock.assert_called_once_with(Path(full_backup_path), password) assert mocked_write_text.call_args[0][0] == expected_restore_file assert mocked_service_call.called @@ -2358,7 +2587,7 @@ async def test_restore_backup_wrong_password( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) @@ -2375,6 +2604,7 @@ async def test_restore_backup_wrong_password( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2382,6 +2612,7 @@ async def test_restore_backup_wrong_password( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": "password_incorrect", "stage": None, "state": RestoreBackupState.FAILED, } @@ -2401,23 +2632,27 @@ async def test_restore_backup_wrong_password( @pytest.mark.usefixtures("path_glob") @pytest.mark.parametrize( - ("parameters", "expected_error"), + ("parameters", "expected_error", "expected_reason"), [ ( - {"backup_id": TEST_BACKUP_DEF456.backup_id}, - f"Backup def456 not found in agent {LOCAL_AGENT_ID}", + {"backup_id": "no_such_backup"}, + f"Backup no_such_backup not found in agent {LOCAL_AGENT_ID}", + "backup_manager_error", ), ( {"restore_addons": ["blah"]}, "Addons and folders are not supported in core restore", + "backup_reader_writer_error", ), ( {"restore_folders": [Folder.ADDONS]}, "Addons and folders are not supported in core restore", + "backup_reader_writer_error", ), ( {"restore_database": False, "restore_homeassistant": False}, "Home Assistant or database must be included in restore", + "backup_reader_writer_error", ), ], ) @@ -2426,6 +2661,7 @@ async def test_restore_backup_wrong_parameters( hass_ws_client: WebSocketGenerator, parameters: dict[str, Any], expected_error: str, + expected_reason: str, ) -> None: """Test restore backup wrong parameters.""" await async_setup_component(hass, DOMAIN, {}) @@ -2447,7 +2683,7 @@ async def test_restore_backup_wrong_parameters( patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): await ws_client.send_json_auto_id( @@ -2462,6 +2698,7 @@ async def test_restore_backup_wrong_parameters( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2469,6 +2706,7 @@ async def test_restore_backup_wrong_parameters( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": expected_reason, "stage": None, "state": RestoreBackupState.FAILED, } @@ -2518,10 +2756,20 @@ async def test_restore_backup_when_busy( @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("exception", "error_code", "error_message"), + ("exception", "error_code", "error_message", "expected_reason"), [ - (BackupAgentError("Boom!"), "home_assistant_error", "Boom!"), - (Exception("Boom!"), "unknown_error", "Unknown error"), + ( + BackupAgentError("Boom!"), + "home_assistant_error", + "Boom!", + "backup_agent_error", + ), + ( + Exception("Boom!"), + "unknown_error", + "Unknown error", + "unknown_error", + ), ], ) async def test_restore_backup_agent_error( @@ -2530,6 +2778,7 @@ async def test_restore_backup_agent_error( exception: Exception, error_code: str, error_message: str, + expected_reason: str, ) -> None: """Test restore backup with agent error.""" remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) @@ -2572,6 +2821,7 @@ async def test_restore_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2579,6 +2829,7 @@ async def test_restore_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": expected_reason, "stage": None, "state": RestoreBackupState.FAILED, } @@ -2719,6 +2970,7 @@ async def test_restore_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2726,6 +2978,7 @@ async def test_restore_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": "unknown_error", "stage": None, "state": RestoreBackupState.FAILED, } @@ -2745,3 +2998,307 @@ async def test_restore_backup_file_error( assert open_mock.return_value.close.call_count == close_call_count assert mocked_write_text.call_count == write_text_call_count assert mocked_service_call.call_count == 0 + + +@pytest.mark.parametrize( + ("commands", "password", "protected_backup"), + [ + ( + [], + None, + {"backup.local": False, "test.remote": False}, + ), + ( + [], + "hunter2", + {"backup.local": True, "test.remote": True}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": False}, + }, + } + ], + "hunter2", + {"backup.local": False, "test.remote": False}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + "hunter2", + {"backup.local": False, "test.remote": True}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": True}, + "test.remote": {"protected": False}, + }, + } + ], + "hunter2", + {"backup.local": True, "test.remote": False}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": True}, + "test.remote": {"protected": True}, + }, + } + ], + "hunter2", + {"backup.local": True, "test.remote": True}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + None, + {"backup.local": False, "test.remote": False}, + ), + ], +) +@pytest.mark.usefixtures("mock_backup_generation") +async def test_initiate_backup_per_agent_encryption( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + commands: dict[str, Any], + password: str | None, + protected_backup: dict[str, bool], +) -> None: + """Test generate backup where encryption is selectively set on agents.""" + agent_ids = ["backup.local", "test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + for command in commands: + await ws_client.send_json_auto_id(command) + result = await ws_client.receive_json() + assert result["success"] is True + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.open", mock_open(read_data=b"test")), + ): + await ws_client.send_json_auto_id( + { + "type": "backup/generate", + "agent_ids": agent_ids, + "password": password, + "name": "test", + } + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + await ws_client.send_json_auto_id( + {"type": "backup/details", "backup_id": backup_id} + ) + result = await ws_client.receive_json() + + backup_data = result["result"]["backup"] + + assert backup_data == { + "addons": [], + "agents": { + agent_id: {"protected": protected_backup[agent_id], "size": ANY} + for agent_id in agent_ids + }, + "backup_id": backup_id, + "database_included": True, + "date": ANY, + "failed_agent_ids": [], + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.1.0", + "name": "test", + "with_automatic_settings": False, + } + + +@pytest.mark.parametrize( + ("restore_result", "last_non_idle_event"), + [ + ( + {"error": None, "error_type": None, "success": True}, + { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + ), + ( + {"error": "Boom!", "error_type": "ValueError", "success": False}, + { + "manager_state": "restore_backup", + "reason": "Boom!", + "stage": None, + "state": "failed", + }, + ), + ], +) +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + restore_result: dict[str, Any], + last_non_idle_event: dict[str, Any], +) -> None: + """Test restore backup progress after restart.""" + + with patch( + "pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode() + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": last_non_idle_event, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + +async def test_restore_progress_after_restart_fail_to_remove( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test restore backup progress after restart when failing to remove result file.""" + + with patch("pathlib.Path.unlink", side_effect=OSError("Boom!")): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + assert ( + "Unexpected error deleting backup restore result file: Boom!" + in caplog.text + ) diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index cc84b66340c..f05afbea9ec 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -66,6 +66,7 @@ def mock_delay_save() -> Generator[None]: } ], "config": { + "agents": {"test.remote": {"protected": True}}, "create_backup": { "agent_ids": [], "include_addons": None, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 60cfc77b1aa..db759805c8f 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -2,13 +2,24 @@ from __future__ import annotations +from collections.abc import AsyncIterator +import dataclasses import tarfile from unittest.mock import Mock, patch import pytest +import securetar -from homeassistant.components.backup import AddonInfo, AgentBackup, Folder -from homeassistant.components.backup.util import read_backup, validate_password +from homeassistant.components.backup import DOMAIN, AddonInfo, AgentBackup, Folder +from homeassistant.components.backup.util import ( + DecryptedBackupStreamer, + EncryptedBackupStreamer, + read_backup, + validate_password, +) +from homeassistant.core import HomeAssistant + +from tests.common import get_fixture_path @pytest.mark.parametrize( @@ -130,3 +141,246 @@ def test_validate_password_no_homeassistant() -> None: KeyError ) assert validate_password(mock_path, "hunter2") is False + + +async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None: + """Test the decrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2") + assert decryptor.backup() == dataclasses.replace( + backup, protected=False, size=backup.size + len(expected_padding) + ) + decrypted_stream = await decryptor.open_stream() + decrypted_output = b"" + async for chunk in decrypted_stream: + decrypted_output += chunk + await decryptor.wait() + + # Expect the output to match the stored decrypted backup file, with additional + # padding. + decrypted_backup_data = decrypted_backup_path.read_bytes() + assert decrypted_output == decrypted_backup_data + expected_padding + + +async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> None: + """Test the decrypted backup streamer with wrong password.""" + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "wrong_password") + decrypted_stream = await decryptor.open_stream() + async for _ in decrypted_stream: + pass + + await decryptor.wait() + assert isinstance(decryptor._workers[0].error, securetar.SecureTarReadError) + + +async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + # Patch os.urandom to return values matching the nonce used in the encrypted + # test backup. The backup has three inner tar files, but we need an extra nonce + # for a future planned supervisor.tar. + with patch("os.urandom") as mock_randbytes: + mock_randbytes.side_effect = ( + bytes.fromhex("bd34ea6fc93b0614ce7af2b44b4f3957"), + bytes.fromhex("1296d6f7554e2cb629a3dc4082bae36c"), + bytes.fromhex("8b7a58e48faf2efb23845eb3164382e0"), + bytes.fromhex("00000000000000000000000000000000"), + ) + encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + assert encryptor.backup() == dataclasses.replace( + backup, protected=True, size=backup.size + len(expected_padding) + ) + + encrypted_stream = await encryptor.open_stream() + encrypted_output = b"" + async for chunk in encrypted_stream: + encrypted_output += chunk + await encryptor.wait() + + # Expect the output to match the stored encrypted backup file, with additional + # padding. + encrypted_backup_data = encrypted_backup_path.read_bytes() + assert encrypted_output == encrypted_backup_data + expected_padding + + +async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + encryptor1 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + encryptor2 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + + async def read_stream(stream: AsyncIterator[bytes]) -> bytes: + output = b"" + async for chunk in stream: + output += chunk + return output + + # When reading twice from the same streamer, the same nonce is used. + encrypted_output1 = await read_stream(await encryptor1.open_stream()) + encrypted_output2 = await read_stream(await encryptor1.open_stream()) + assert encrypted_output1 == encrypted_output2 + + encrypted_output3 = await read_stream(await encryptor2.open_stream()) + encrypted_output4 = await read_stream(await encryptor2.open_stream()) + assert encrypted_output3 == encrypted_output4 + + # Wait for workers to terminate + await encryptor1.wait() + await encryptor2.wait() + + # Output from the two streames should differ but have the same length. + assert encrypted_output1 != encrypted_output3 + assert len(encrypted_output1) == len(encrypted_output3) + + # Expect the output length to match the stored encrypted backup file, with + # additional padding. + encrypted_backup_data = encrypted_backup_path.read_bytes() + # 4 x 10240 byte of padding + assert len(encrypted_output1) == len(encrypted_backup_data) + 40960 + assert encrypted_output1[: len(encrypted_backup_data)] != encrypted_backup_data + + +async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + # Patch os.urandom to return values matching the nonce used in the encrypted + # test backup. The backup has three inner tar files, but we need an extra nonce + # for a future planned supervisor.tar. + encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + + with patch( + "homeassistant.components.backup.util.tarfile.open", + side_effect=tarfile.TarError, + ): + encrypted_stream = await encryptor.open_stream() + async for _ in encrypted_stream: + pass + + # Expect the output to match the stored encrypted backup file, with additional + # padding. + await encryptor.wait() + assert isinstance(encryptor._workers[0].error, tarfile.TarError) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 52c04474162..613c0b69b6b 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -56,6 +56,7 @@ BACKUP_CALL = call( DEFAULT_STORAGE_DATA: dict[str, Any] = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": [], "include_addons": None, @@ -587,6 +588,8 @@ async def test_generate_with_default_settings_calls_create( last_completed_automatic_backup: str, ) -> None: """Test backup/generate_with_automatic_settings calls async_initiate_backup.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = create_backup_settings["password"] is not None client = await hass_ws_client(hass) await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-13T12:01:00+01:00") @@ -913,6 +916,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -943,6 +947,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -973,6 +978,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1003,6 +1009,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1033,6 +1040,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1063,6 +1071,41 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon", "sun"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, + }, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1115,80 +1158,130 @@ async def test_config_info( @pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") @pytest.mark.parametrize( - "command", + "commands", [ - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 7}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"recurrence": "daily", "time": "06:00"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"days": ["mon"], "recurrence": "custom_days"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"recurrence": "never"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"}, - }, - { - "type": "backup/config/update", - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": ["test-addon"], - "include_folders": ["media"], - "name": "test-name", - "password": "test-password", + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 7}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"recurrence": "daily", "time": "06:00"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"recurrence": "never"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3, "days": 7}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 7}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"days": 7}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, + }, + } + ], + [ + # Test we can update AgentConfig + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, + }, }, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3, "days": 7}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": None}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3, "days": None}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 7}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"days": 7}, - "schedule": {"recurrence": "daily"}, - }, + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": False}, + "test-agent2": {"protected": True}, + }, + }, + ], ], ) @patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) @@ -1197,7 +1290,7 @@ async def test_config_update( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, - command: dict[str, Any], + commands: dict[str, Any], hass_storage: dict[str, Any], ) -> None: """Test updating the backup config.""" @@ -1211,14 +1304,14 @@ async def test_config_update( await client.send_json_auto_id({"type": "backup/config/info"}) assert await client.receive_json() == snapshot - await client.send_json_auto_id(command) - result = await client.receive_json() + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] - assert result["success"] - - await client.send_json_auto_id({"type": "backup/config/info"}) - assert await client.receive_json() == snapshot - await hass.async_block_till_done() + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + await hass.async_block_till_done() # Trigger store write freezer.tick(60) @@ -1274,6 +1367,10 @@ async def test_config_update( "type": "backup/config/update", "create_backup": {"include_folders": ["media", "media"]}, }, + { + "type": "backup/config/update", + "agents": {"test-agent1": {"favorite": True}}, + }, ], ) async def test_config_update_errors( @@ -1600,10 +1697,14 @@ async def test_config_schedule_logic( create_backup_side_effect: list[Exception | None] | None, ) -> None: """Test config schedule logic.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test.test-agent"], "include_addons": ["test-addon"], @@ -2057,10 +2158,14 @@ async def test_config_retention_copies_logic( delete_args_list: Any, ) -> None: """Test config backup retention copies logic.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2320,10 +2425,14 @@ async def test_config_retention_copies_logic_manual_backup( delete_args_list: Any, ) -> None: """Test config backup retention copies logic for manual backup.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2750,6 +2859,7 @@ async def test_config_retention_days_logic( storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2816,7 +2926,7 @@ async def test_subscribe_event( assert await client.receive_json() == snapshot manager.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS, reason=None) ) assert await client.receive_json() == snapshot diff --git a/tests/components/balboa/test_config_flow.py b/tests/components/balboa/test_config_flow.py index afa170577df..d81edaad3b4 100644 --- a/tests/components/balboa/test_config_flow.py +++ b/tests/components/balboa/test_config_flow.py @@ -3,19 +3,23 @@ from unittest.mock import MagicMock, patch from pybalboa.exceptions import SpaConnectionError +import pytest from homeassistant import config_entries from homeassistant.components.balboa.const import CONF_SYNC_TIME, DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry -TEST_DATA = { - CONF_HOST: "1.1.1.1", -} -TEST_ID = "FakeBalboa" +TEST_HOST = "1.1.1.1" +TEST_DATA = {CONF_HOST: TEST_HOST} +TEST_MAC = "ef:ef:ef:c0:ff:ee" +TEST_DHCP_SERVICE_INFO = DhcpServiceInfo( + ip=TEST_HOST, macaddress=TEST_MAC.replace(":", ""), hostname="fakespa" +) async def test_form(hass: HomeAssistant, client: MagicMock) -> None: @@ -107,7 +111,7 @@ async def test_unknown_error(hass: HomeAssistant, client: MagicMock) -> None: async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> None: """Test when provided credentials are already configured.""" - MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -138,7 +142,7 @@ async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> Non async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None: """Test specifying non default settings using options flow.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID) + config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -161,3 +165,111 @@ async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert dict(config_entry.options) == {CONF_SYNC_TIME: True} + + +async def test_dhcp_discovery(hass: HomeAssistant, client: MagicMock) -> None: + """Test we can process the discovery from dhcp.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "FakeSpa" + assert result["data"] == TEST_DATA + assert result["result"].unique_id == TEST_MAC + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_updates_host( + hass: HomeAssistant, client: MagicMock +) -> None: + """Test dhcp discovery updates host and aborts.""" + entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC) + entry.add_to_hass(hass) + + updated_ip = "1.1.1.2" + TEST_DHCP_SERVICE_INFO.ip = updated_ip + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == updated_ip + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (SpaConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_dhcp_discovery_failed( + hass: HomeAssistant, client: MagicMock, side_effect: Exception, reason: str +) -> None: + """Test failed setup from dhcp.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + side_effect=side_effect(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_dhcp_discovery_manual_user_setup( + hass: HomeAssistant, client: MagicMock +) -> None: + """Test dhcp discovery with manual user setup.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == TEST_DATA diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index 8a0132ff2af..59fbdf9a253 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockBinarySensor diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 78e382f77bf..dd71c1e5d06 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockBinarySensor diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index e3bdca256c0..acd630863d2 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -29,7 +29,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads from . import ( diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index abb3a5e2393..0070bebe4b6 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -487,6 +487,33 @@ async def test_options_flow_remote_adapter(hass: HomeAssistant) -> None: assert result["reason"] == "remote_adapters_not_supported" +@pytest.mark.usefixtures( + "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) +async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> None: + """Test options are not available for local adapters without passive support.""" + source_entry = MockConfigEntry( + domain="test", + ) + source_entry.add_to_hass(hass) + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + unique_id="BB:BB:BB:BB:BB:BB", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + _get_manager()._adapters["hci0"]["passive_scan"] = False + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "local_adapters_no_passive_support" + + @pytest.mark.usefixtures("one_adapter") async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: """Test we give a hint that the adapter is ignored.""" diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index be4412db4d8..384eae7e49a 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -133,6 +133,7 @@ async def test_diagnostics( } }, "manager": { + "allocations": {}, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -291,6 +292,7 @@ async def test_diagnostics_macos( } }, "manager": { + "allocations": {}, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", @@ -484,6 +486,7 @@ async def test_diagnostics_remote_adapter( }, "dbus": {}, "manager": { + "allocations": {}, "adapters": { "hci0": { "address": "00:00:00:00:00:01", diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index c7fc80ba068..be23a536f49 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -426,7 +426,7 @@ async def test_restore_history_from_dbus( address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), - HCI0_SOURCE_ADDRESS, + "hci0", ) } @@ -438,6 +438,8 @@ async def test_restore_history_from_dbus( await hass.async_block_till_done() assert bluetooth.async_ble_device_from_address(hass, address) is ble_device + info = bluetooth.async_last_service_info(hass, address, False) + assert info.source == "00:00:00:00:00:01" @pytest.mark.usefixtures("one_adapter") diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index d9289fe8380..bacdbbd5eed 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -17,6 +17,7 @@ from . import ( HCI0_SOURCE_ADDRESS, HCI1_SOURCE_ADDRESS, NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + FakeScanner, _get_manager, generate_advertisement_data, generate_ble_device, @@ -123,7 +124,7 @@ async def test_subscribe_advertisements( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations( +async def test_subscribe_connection_allocations( hass: HomeAssistant, register_hci0_scanner: None, register_hci1_scanner: None, @@ -201,7 +202,7 @@ async def test_subscribe_subscribe_connection_allocations( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_specific_scanner( +async def test_subscribe_connection_allocations_specific_scanner( hass: HomeAssistant, register_non_connectable_scanner: None, hass_ws_client: WebSocketGenerator, @@ -237,7 +238,7 @@ async def test_subscribe_subscribe_connection_allocations_specific_scanner( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_invalid_config_entry_id( +async def test_subscribe_connection_allocations_invalid_config_entry_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: @@ -258,7 +259,7 @@ async def test_subscribe_subscribe_connection_allocations_invalid_config_entry_i @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_invalid_scanner( +async def test_subscribe_connection_allocations_invalid_scanner( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: @@ -278,3 +279,136 @@ async def test_subscribe_subscribe_connection_allocations_invalid_scanner( assert not response["success"] assert response["error"]["code"] == "invalid_source" assert response["error"]["message"] == "Source invalid not found" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"] == { + "add": [ + { + "adapter": "hci0", + "connectable": False, + "name": "hci0 (00:00:00:00:00:01)", + "source": "00:00:00:00:00:01", + } + ] + } + + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + cancel_hci3() + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "remove": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details_specific_scanner( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_details for a specific source address.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="AA:BB:CC:DD:EE:33") + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + cancel_hci3() + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "remove": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details_invalid_config_entry_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_details for an invalid config entry id.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + "config_entry_id": "non_existent", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_config_entry_id" + assert response["error"]["message"] == "Invalid config entry id: non_existent" diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index 7d1b787ff0b..2b2e9257097 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -1,17 +1,21 @@ """Common fixtures for the Bring! tests.""" from collections.abc import Generator -from typing import cast from unittest.mock import AsyncMock, patch import uuid -from bring_api.types import BringAuthResponse +from bring_api.types import ( + BringAuthResponse, + BringItemsResponse, + BringListResponse, + BringUserSettingsResponse, +) import pytest from homeassistant.components.bring.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry, load_fixture EMAIL = "test-email" PASSWORD = "test-password" @@ -44,11 +48,17 @@ def mock_bring_client() -> Generator[AsyncMock]: client = mock_client.return_value client.uuid = UUID client.mail = EMAIL - client.login.return_value = cast(BringAuthResponse, {"name": "Bring"}) - client.load_lists.return_value = load_json_object_fixture("lists.json", DOMAIN) - client.get_list.return_value = load_json_object_fixture("items.json", DOMAIN) - client.get_all_user_settings.return_value = load_json_object_fixture( - "usersettings.json", DOMAIN + client.login.return_value = BringAuthResponse.from_json( + load_fixture("login.json", DOMAIN) + ) + client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists.json", DOMAIN) + ) + client.get_list.return_value = BringItemsResponse.from_json( + load_fixture("items.json", DOMAIN) + ) + client.get_all_user_settings.return_value = BringUserSettingsResponse.from_json( + load_fixture("usersettings.json", DOMAIN) ) yield client diff --git a/tests/components/bring/fixtures/items.json b/tests/components/bring/fixtures/items.json index e0b9006167b..eecdbaac8c7 100644 --- a/tests/components/bring/fixtures/items.json +++ b/tests/components/bring/fixtures/items.json @@ -1,44 +1,46 @@ { "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "status": "REGISTERED", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json index 82ef623e439..be3671c359a 100644 --- a/tests/components/bring/fixtures/items_invitation.json +++ b/tests/components/bring/fixtures/items_invitation.json @@ -1,44 +1,46 @@ { "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "status": "INVITATION", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json index 9ac999729d3..5e381d27ca8 100644 --- a/tests/components/bring/fixtures/items_shared.json +++ b/tests/components/bring/fixtures/items_shared.json @@ -1,44 +1,46 @@ { "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "status": "SHARED", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/login.json b/tests/components/bring/fixtures/login.json new file mode 100644 index 00000000000..62616471734 --- /dev/null +++ b/tests/components/bring/fixtures/login.json @@ -0,0 +1,12 @@ +{ + "uuid": "4d717571-174a-4bc1-ab24-929c7227ca43", + "publicUuid": "9a21fdfc-63a4-441a-afc1-ef3030605a9d", + "email": "test-email", + "name": "Bring", + "photoPath": "", + "bringListUUID": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + "access_token": "ACCESS_TOKEN", + "refresh_token": "REFRESH_TOKEN", + "token_type": "Bearer", + "expires_in": 604799 +} diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 6d830a12133..5955ded832a 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -2,100 +2,112 @@ # name: test_diagnostics dict({ 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ - 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - 'name': 'Baumarkt', - 'purchase': list([ - dict({ - 'attributes': list([ + 'content': dict({ + 'items': dict({ + 'purchase': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ + 'recently': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', }), ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', - }), - ]), - 'status': 'REGISTERED', - 'theme': 'ch.publisheria.bring.theme.home', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'status': 'REGISTERED', + 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + }), + 'lst': dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), }), 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ - 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', - 'name': 'Einkauf', - 'purchase': list([ - dict({ - 'attributes': list([ + 'content': dict({ + 'items': dict({ + 'purchase': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ + 'recently': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', }), ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', - }), - ]), - 'status': 'REGISTERED', - 'theme': 'ch.publisheria.bring.theme.home', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'status': 'REGISTERED', + 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + }), + 'lst': dict({ + 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + 'name': 'Einkauf', + 'theme': 'ch.publisheria.bring.theme.home', + }), }), }) # --- diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py index 974818ccedf..442fea5a247 100644 --- a/tests/components/bring/test_sensor.py +++ b/tests/components/bring/test_sensor.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from bring_api import BringItemsResponse import pytest from syrupy.assertion import SnapshotAssertion @@ -12,7 +13,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform +from tests.common import MockConfigEntry, load_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -62,10 +63,9 @@ async def test_list_access_states( ) -> None: """Snapshot test states of list access sensor.""" - mock_bring_client.get_list.return_value = load_json_object_fixture( - f"{fixture}.json", DOMAIN + mock_bring_client.get_list.return_value = BringItemsResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) - bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 88379530362..3060f31c134 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -1,15 +1,13 @@ """Test for utility functions of the Bring! integration.""" -from typing import cast - -from bring_api import BringUserSettingsResponse +from bring_api import BringItemsResponse, BringListResponse, BringUserSettingsResponse import pytest from homeassistant.components.bring.const import DOMAIN from homeassistant.components.bring.coordinator import BringData from homeassistant.components.bring.util import list_language, sum_attributes -from tests.common import load_json_object_fixture +from tests.common import load_fixture @pytest.mark.parametrize( @@ -17,7 +15,7 @@ from tests.common import load_json_object_fixture [ ("e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "de-DE"), ("b4776778-7f6c-496e-951b-92a35d3db0dd", "en-US"), - ("00000000-0000-0000-0000-00000000", None), + ("00000000-0000-0000-0000-000000000000", None), ], ) def test_list_language(list_uuid: str, expected: str | None) -> None: @@ -25,10 +23,7 @@ def test_list_language(list_uuid: str, expected: str | None) -> None: result = list_language( list_uuid, - cast( - BringUserSettingsResponse, - load_json_object_fixture("usersettings.json", DOMAIN), - ), + BringUserSettingsResponse.from_json(load_fixture("usersettings.json", DOMAIN)), ) assert result == expected @@ -44,12 +39,11 @@ def test_list_language(list_uuid: str, expected: str | None) -> None: ) def test_sum_attributes(attribute: str, expected: int) -> None: """Test function sum_attributes.""" + items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)) + lst = BringListResponse.from_json(load_fixture("lists.json", DOMAIN)) result = sum_attributes( - cast( - BringData, - load_json_object_fixture("items.json", DOMAIN), - ), + BringData(lst.lists[0], items), attribute, ) diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py index 7ee12c5fa1a..41d566fc375 100644 --- a/tests/components/bsblan/test_climate.py +++ b/tests/components/bsblan/test_climate.py @@ -22,7 +22,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py index c95671a1a6b..ba2af40f319 100644 --- a/tests/components/bsblan/test_sensor.py +++ b/tests/components/bsblan/test_sensor.py @@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py index ed920774aa5..173498b14ff 100644 --- a/tests/components/bsblan/test_water_heater.py +++ b/tests/components/bsblan/test_water_heater.py @@ -20,7 +20,7 @@ from homeassistant.components.water_heater import ( from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 36b102b933a..2d712f408c2 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.calendar import DOMAIN, SERVICE_GET_EVENTS from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockCalendarEntity, MockConfigEntry diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index dfe4622e82e..b0d7944041d 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -25,7 +25,7 @@ from homeassistant.components.calendar.trigger import EVENT_END, EVENT_START from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockCalendarEntity diff --git a/tests/components/clicksend_tts/test_notify.py b/tests/components/clicksend_tts/test_notify.py index 892d7541354..811978eead5 100644 --- a/tests/components/clicksend_tts/test_notify.py +++ b/tests/components/clicksend_tts/test_notify.py @@ -9,7 +9,7 @@ import pytest import requests_mock from homeassistant.components import notify -import homeassistant.components.clicksend_tts.notify as cs_tts +from homeassistant.components.clicksend_tts import notify as cs_tts from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index db742525a48..c2513168ab9 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -146,7 +146,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "cloud.cloud"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "cloud.cloud", "name": "cloud"}, + ], } @@ -167,6 +170,7 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, @@ -174,9 +178,6 @@ async def test_agents_list_backups( "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": ["cloud.cloud"], "failed_agent_ids": [], "with_automatic_settings": None, } @@ -204,8 +205,10 @@ async def test_agents_list_backups_fail_cloud( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } @@ -216,6 +219,7 @@ async def test_agents_list_backups_fail_cloud( "23e64aec", { "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, @@ -223,9 +227,6 @@ async def test_agents_list_backups_fail_cloud( "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": ["cloud.cloud"], "failed_agent_ids": [], "with_automatic_settings": None, }, diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index d165a129dbe..d131d211e2f 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -12,7 +12,7 @@ from homeassistant.components.cloud.repairs import ( ) from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index d629607e503..15a6c5740ff 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -15,7 +15,7 @@ from homeassistant.components.cloudflare.const import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.location import LocationInfo from . import ENTRY_CONFIG, init_integration diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index 23ba5e7808c..3f920b7dee2 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -25,7 +25,7 @@ from homeassistant.components.light import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 426968eccc5..a6e384fdd6b 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run diff --git a/tests/components/command_line/test_init.py b/tests/components/command_line/test_init.py index 3fbd0e0f898..16a783d4f59 100644 --- a/tests/components/command_line/test_init.py +++ b/tests/components/command_line/test_init.py @@ -11,7 +11,7 @@ from homeassistant import config as hass_config from homeassistant.components.command_line.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, STATE_ON, STATE_OPEN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, get_fixture_path diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index d62410fa792..6b34cf0fa77 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py index a4faab483ee..1985c6e5c8c 100644 --- a/tests/components/configurator/test_init.py +++ b/tests/components/configurator/test_init.py @@ -5,7 +5,7 @@ from datetime import timedelta from homeassistant.components import configurator from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 0cd33e28d35..ebf390e30d7 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -535,6 +535,7 @@ def supervisor_client() -> Generator[AsyncMock]: supervisor_client.discovery = AsyncMock() supervisor_client.homeassistant = AsyncMock() supervisor_client.host = AsyncMock() + supervisor_client.jobs = AsyncMock() supervisor_client.mounts.info.return_value = mounts_info_mock supervisor_client.os = AsyncMock() supervisor_client.resolution = AsyncMock() diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 1102a41e6c3..c6ac6c2df9c 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -49,6 +49,7 @@ 'sk', 'sl', 'sr', + 'sr-Latn', 'sv', 'sw', 'te', @@ -539,7 +540,7 @@ 'name': 'HassTurnOn', }), 'match': True, - 'sentence_template': ' on [] ', + 'sentence_template': ' on [(|)] ', 'slots': dict({ 'area': 'kitchen', 'domain': 'light', @@ -638,7 +639,7 @@ 'brightness': dict({ 'name': 'brightness', 'text': '100', - 'value': 100, + 'value': 100.0, }), 'name': dict({ 'name': 'name', @@ -690,7 +691,7 @@ 'targets': dict({ }), 'unmatched_slots': dict({ - 'brightness': 1001, + 'brightness': 1001.0, }), }), ]), diff --git a/tests/components/conversation/test_entity.py b/tests/components/conversation/test_entity.py index 109c0ed361f..f03b24818bf 100644 --- a/tests/components/conversation/test_entity.py +++ b/tests/components/conversation/test_entity.py @@ -6,7 +6,7 @@ from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers import intent from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import mock_restore_cache diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_session.py index bca19b3b06a..60c7f2957b8 100644 --- a/tests/components/conversation/test_session.py +++ b/tests/components/conversation/test_session.py @@ -82,7 +82,7 @@ async def test_cleanup( assert chat_session.conversation_id != conversation_id conversation_id = chat_session.conversation_id chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", @@ -127,12 +127,6 @@ async def test_cleanup( assert len(chat_session.messages) == 2 -def test_chat_message() -> None: - """Test chat message.""" - with pytest.raises(ValueError): - session.ChatMessage(role="native", agent_id=None, content="", native=None) - - async def test_add_message( hass: HomeAssistant, mock_conversation_input: ConversationInput ) -> None: @@ -144,7 +138,7 @@ async def test_add_message( with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage(role="system", agent_id=None, content="") + session.Content(role="system", agent_id=None, content="") ) # No 2 user messages in a row @@ -152,19 +146,19 @@ async def test_add_message( with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage(role="user", agent_id=None, content="") + session.Content(role="user", agent_id=None, content="") ) # No 2 assistant messages in a row chat_session.async_add_message( - session.ChatMessage(role="assistant", agent_id=None, content="") + session.Content(role="assistant", agent_id=None, content="") ) assert len(chat_session.messages) == 3 assert chat_session.messages[-1].role == "assistant" with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage(role="assistant", agent_id=None, content="") + session.Content(role="assistant", agent_id=None, content="") ) @@ -177,12 +171,12 @@ async def test_message_filtering( ) as chat_session: messages = chat_session.async_get_messages(agent_id=None) assert len(messages) == 2 - assert messages[0] == session.ChatMessage( + assert messages[0] == session.Content( role="system", agent_id=None, content="", ) - assert messages[1] == session.ChatMessage( + assert messages[1] == session.Content( role="user", agent_id="mock-agent-id", content=mock_conversation_input.text, @@ -190,7 +184,7 @@ async def test_message_filtering( # Cannot add a second user message in a row with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage( + session.Content( role="user", agent_id="mock-agent-id", content="Hey!", @@ -198,31 +192,25 @@ async def test_message_filtering( ) chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", - native="assistant-reply-native", ) ) # Different agent, native messages will be filtered out. chat_session.async_add_message( - session.ChatMessage( - role="native", agent_id="another-mock-agent-id", content="", native=1 - ) + session.NativeContent(agent_id="another-mock-agent-id", content=1) ) chat_session.async_add_message( - session.ChatMessage( - role="native", agent_id="mock-agent-id", content="", native=1 - ) + session.NativeContent(agent_id="mock-agent-id", content=1) ) # A non-native message from another agent is not filtered out. chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="another-mock-agent-id", content="Hi!", - native=1, ) ) @@ -231,17 +219,14 @@ async def test_message_filtering( messages = chat_session.async_get_messages(agent_id="mock-agent-id") assert len(messages) == 5 - assert messages[2] == session.ChatMessage( + assert messages[2] == session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", - native="assistant-reply-native", ) - assert messages[3] == session.ChatMessage( - role="native", agent_id="mock-agent-id", content="", native=1 - ) - assert messages[4] == session.ChatMessage( - role="assistant", agent_id="another-mock-agent-id", content="Hi!", native=1 + assert messages[3] == session.NativeContent(agent_id="mock-agent-id", content=1) + assert messages[4] == session.Content( + role="assistant", agent_id="another-mock-agent-id", content="Hi!" ) @@ -361,7 +346,7 @@ async def test_extra_systen_prompt( user_llm_prompt=None, ) chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", @@ -401,7 +386,7 @@ async def test_extra_systen_prompt( user_llm_prompt=None, ) chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index 096b2abf958..7d84e7ac83e 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -9,6 +9,7 @@ from cookidoo_api import ( CookidooAuthResponse, CookidooIngredientItem, CookidooSubscription, + CookidooUserInfo, ) import pytest @@ -58,7 +59,9 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: client.get_active_subscription.return_value = CookidooSubscription( **load_json_object_fixture("subscriptions.json", DOMAIN)["data"] ) - + client.get_user_info.return_value = CookidooUserInfo( + **load_json_object_fixture("user_info.json", DOMAIN)["data"] + ) client.login.return_value = CookidooAuthResponse( **load_json_object_fixture("login.json", DOMAIN) ) diff --git a/tests/components/cookidoo/fixtures/user_info.json b/tests/components/cookidoo/fixtures/user_info.json new file mode 100644 index 00000000000..1c99ae84823 --- /dev/null +++ b/tests/components/cookidoo/fixtures/user_info.json @@ -0,0 +1,7 @@ +{ + "data": { + "username": "username_1234", + "description": null, + "picture": null + } +} diff --git a/tests/components/cookidoo/snapshots/test_diagnostics.ambr b/tests/components/cookidoo/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..3dc799c1108 --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_diagnostics.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'additional_items': list([ + dict({ + 'id': 'unique_id_tomaten', + 'is_owned': False, + 'name': 'Tomaten', + }), + ]), + 'ingredient_items': list([ + dict({ + 'description': '200 g', + 'id': 'unique_id_mehl', + 'is_owned': False, + 'name': 'Mehl', + }), + ]), + 'subscription': dict({ + 'active': True, + 'expires': '2025-12-16T23:59:00Z', + 'extended_type': 'REGULAR', + 'start_date': '2024-12-16T00:00:00Z', + 'status': 'ACTIVE', + 'subscription_level': 'FULL', + 'subscription_source': 'COMMERCE', + 'type': 'REGULAR', + }), + }), + 'entry_data': dict({ + 'country': 'CH', + 'email': 'test-email', + 'language': 'de-CH', + 'password': '**REDACTED**', + }), + 'user': dict({ + 'description': None, + 'picture': None, + 'username': 'username_1234', + }), + }) +# --- diff --git a/tests/components/cookidoo/test_diagnostics.py b/tests/components/cookidoo/test_diagnostics.py new file mode 100644 index 00000000000..c253e1f6e09 --- /dev/null +++ b/tests/components/cookidoo/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the Cookidoo integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, cookidoo_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, cookidoo_config_entry) + == snapshot + ) diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index a6c10d4acf1..7901baaa3b8 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockCover diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py index 97cad5bbe14..dcec921c01d 100644 --- a/tests/components/demo/test_cover.py +++ b/tests/components/demo/test_cover.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/demo/test_geo_location.py b/tests/components/demo/test_geo_location.py index d3c2937d12b..a93c79828d6 100644 --- a/tests/components/demo/test_geo_location.py +++ b/tests/components/demo/test_geo_location.py @@ -15,7 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py index 98b3de8448a..f3677c6e373 100644 --- a/tests/components/demo/test_notify.py +++ b/tests/components/demo/test_notify.py @@ -6,8 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.components import notify -from homeassistant.components.demo import DOMAIN -import homeassistant.components.demo.notify as demo +from homeassistant.components.demo import DOMAIN, notify as demo from homeassistant.const import Platform from homeassistant.core import Event, HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 4a4d8519b25..a543de974f1 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/devialet/test_init.py b/tests/components/devialet/test_init.py index a87e8ac05c3..6808ee0983e 100644 --- a/tests/components/devialet/test_init.py +++ b/tests/components/devialet/test_init.py @@ -1,6 +1,5 @@ """Test the Devialet init.""" -from homeassistant.components.devialet.const import DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, MediaPlayerState from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -16,7 +15,6 @@ async def test_load_unload_config_entry( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is not None @@ -26,7 +24,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -36,7 +33,6 @@ async def test_load_unload_config_entry_when_device_unavailable( """Test the Devialet configuration entry loading and unloading when the device is unavailable.""" entry = await setup_integration(hass, aioclient_mock, state="unavailable") - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is not None @@ -46,5 +42,4 @@ async def test_load_unload_config_entry_when_device_unavailable( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/devialet/test_media_player.py b/tests/components/devialet/test_media_player.py index 6ca3d23218f..fd593a10a98 100644 --- a/tests/components/devialet/test_media_player.py +++ b/tests/components/devialet/test_media_player.py @@ -6,7 +6,6 @@ from devialet import DevialetApi from devialet.const import UrlSuffix from yarl import URL -from homeassistant.components.devialet.const import DOMAIN from homeassistant.components.devialet.media_player import SUPPORT_DEVIALET from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY from homeassistant.components.media_player import ( @@ -108,7 +107,6 @@ async def test_media_player_playing( await async_setup_component(hass, "homeassistant", {}) entry = await setup_integration(hass, aioclient_mock) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED await hass.services.async_call( @@ -227,7 +225,6 @@ async def test_media_player_playing( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -237,7 +234,6 @@ async def test_media_player_offline( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock, state=STATE_UNAVAILABLE) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") @@ -247,7 +243,6 @@ async def test_media_player_offline( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -257,14 +252,12 @@ async def test_media_player_without_serial( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock, serial=None) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is None await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -276,7 +269,6 @@ async def test_media_player_services( hass, aioclient_mock, state=MediaPlayerState.PLAYING ) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED target = {ATTR_ENTITY_ID: hass.states.get(f"{MP_DOMAIN}.{NAME}").entity_id} @@ -309,5 +301,4 @@ async def test_media_player_services( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index be4d3bd4c9e..a7b2f8a3b75 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -9,7 +9,7 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 6226669aa0f..ea07365bd2f 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -28,7 +28,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import common from .common import MockScanner, mock_legacy_device_tracker_setup diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 9f3435f0cd9..223dc83f83a 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -31,12 +31,12 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/dremel_3d_printer/test_init.py b/tests/components/dremel_3d_printer/test_init.py index 6b008c7fac1..fda1ecc6cf6 100644 --- a/tests/components/dremel_3d_printer/test_init.py +++ b/tests/components/dremel_3d_printer/test_init.py @@ -12,7 +12,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py index 4bf4eb53ad6..3335e12b2a2 100644 --- a/tests/components/dynalite/test_init.py +++ b/tests/components/dynalite/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import call, patch import pytest from voluptuous import MultipleInvalid -import homeassistant.components.dynalite.const as dynalite +from homeassistant.components.dynalite import const as dynalite from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/eafm/test_sensor.py b/tests/components/eafm/test_sensor.py index add604167b9..11febb26669 100644 --- a/tests/components/eafm/test_sensor.py +++ b/tests/components/eafm/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index 5c919ffab5c..9edb1d42331 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -2,15 +2,8 @@ from unittest.mock import patch -from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN -import pytest - from homeassistant.components.ecobee import config_flow -from homeassistant.components.ecobee.const import ( - CONF_REFRESH_TOKEN, - DATA_ECOBEE_CONFIG, - DOMAIN, -) +from homeassistant.components.ecobee.const import CONF_REFRESH_TOKEN, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant @@ -35,7 +28,6 @@ async def test_user_step_without_user_input(hass: HomeAssistant) -> None: """Test expected result if user step is called.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} result = await flow.async_step_user() assert result["type"] is FlowResultType.FORM @@ -46,7 +38,6 @@ async def test_pin_request_succeeds(hass: HomeAssistant) -> None: """Test expected result if pin request succeeds.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -64,7 +55,6 @@ async def test_pin_request_fails(hass: HomeAssistant) -> None: """Test expected result if pin request fails.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -81,7 +71,6 @@ async def test_token_request_succeeds(hass: HomeAssistant) -> None: """Test expected result if token request succeeds.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -105,7 +94,6 @@ async def test_token_request_fails(hass: HomeAssistant) -> None: """Test expected result if token request fails.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -120,99 +108,3 @@ async def test_token_request_fails(hass: HomeAssistant) -> None: assert result["step_id"] == "authorize" assert result["errors"]["base"] == "token_request_failed" assert result["description_placeholders"] == {"pin": "test-pin"} - - -@pytest.mark.skip(reason="Flaky/slow") -async def test_import_flow_triggered_but_no_ecobee_conf(hass: HomeAssistant) -> None: - """Test expected result if import flow triggers but ecobee.conf doesn't exist.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} - - result = await flow.async_step_import(import_data=None) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - -async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_valid_tokens( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with valid tokens.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - - MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee, - ): - mock_ecobee = mock_ecobee.return_value - mock_ecobee.refresh_tokens.return_value = True - mock_ecobee.api_key = "test-api-key" - mock_ecobee.refresh_token = "test-token" - - result = await flow.async_step_import(import_data=None) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DOMAIN - assert result["data"] == { - CONF_API_KEY: "test-api-key", - CONF_REFRESH_TOKEN: "test-token", - } - - -async def test_import_flow_triggered_with_ecobee_conf_and_invalid_data( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with invalid data.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"} - - MOCK_ECOBEE_CONF = {} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch.object(flow, "async_step_user") as mock_async_step_user, - ): - await flow.async_step_import(import_data=None) - - mock_async_step_user.assert_called_once_with( - user_input={CONF_API_KEY: "test-api-key"} - ) - - -async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_stale_tokens( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with stale tokens.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"} - - MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee, - patch.object(flow, "async_step_user") as mock_async_step_user, - ): - mock_ecobee = mock_ecobee.return_value - mock_ecobee.refresh_tokens.return_value = False - - await flow.async_step_import(import_data=None) - - mock_async_step_user.assert_called_once_with( - user_input={CONF_API_KEY: "test-api-key"} - ) diff --git a/tests/components/ecoforest/conftest.py b/tests/components/ecoforest/conftest.py index 85bfff08bdf..8678cfd4d05 100644 --- a/tests/components/ecoforest/conftest.py +++ b/tests/components/ecoforest/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch from pyecoforest.models.device import Alarm, Device, OperationMode, State import pytest -from homeassistant.components.ecoforest import DOMAIN +from homeassistant.components.ecoforest.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/econet/test_config_flow.py b/tests/components/econet/test_config_flow.py index 2ef10c1bd41..2fc4356d1d8 100644 --- a/tests/components/econet/test_config_flow.py +++ b/tests/components/econet/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch from pyeconet.api import EcoNetApiInterface from pyeconet.errors import InvalidCredentialsError, PyeconetError -from homeassistant.components.econet import DOMAIN +from homeassistant.components.econet.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index addaa1b9c48..49c18bab239 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import MULTI_SENSOR_TOKEN, mock_responses, setup_platform diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index cdad628de6b..ef52eade9ae 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -4,8 +4,9 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.heater import EheimDigitalHeater from eheimdigital.hub import EheimDigitalHub -from eheimdigital.types import EheimDeviceType, LightMode +from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode import pytest from homeassistant.components.eheimdigital.const import DOMAIN @@ -39,7 +40,26 @@ def classic_led_ctrl_mock(): @pytest.fixture -def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMock]: +def heater_mock(): + """Mock a Heater device.""" + heater_mock = MagicMock(spec=EheimDigitalHeater) + heater_mock.mac_address = "00:00:00:00:00:02" + heater_mock.device_type = EheimDeviceType.VERSION_EHEIM_EXT_HEATER + heater_mock.name = "Mock Heater" + heater_mock.aquarium_name = "Mock Aquarium" + heater_mock.temperature_unit = HeaterUnit.CELSIUS + heater_mock.current_temperature = 24.2 + heater_mock.target_temperature = 25.5 + heater_mock.is_heating = True + heater_mock.is_active = True + heater_mock.operation_mode = HeaterMode.MANUAL + return heater_mock + + +@pytest.fixture +def eheimdigital_hub_mock( + classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock +) -> Generator[AsyncMock]: """Mock eheimdigital hub.""" with ( patch( @@ -52,7 +72,8 @@ def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMo ), ): eheimdigital_hub_mock.return_value.devices = { - "00:00:00:00:00:01": classic_led_ctrl_mock + "00:00:00:00:00:01": classic_led_ctrl_mock, + "00:00:00:00:00:02": heater_mock, } eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock yield eheimdigital_hub_mock diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr new file mode 100644 index 00000000000..02d60677b24 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_setup_heater[climate.mock_heater_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_heater_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'heater', + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_heater[climate.mock_heater_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.2, + 'friendly_name': 'Mock Heater None', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 25.5, + }), + 'context': , + 'entity_id': 'climate.mock_heater_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py new file mode 100644 index 00000000000..4e770882263 --- /dev/null +++ b/tests/components/eheimdigital/test_climate.py @@ -0,0 +1,219 @@ +"""Tests for the climate module.""" + +from unittest.mock import MagicMock, patch + +from eheimdigital.types import ( + EheimDeviceType, + EheimDigitalClientError, + HeaterMode, + HeaterUnit, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.components.eheimdigital.const import ( + HEATER_BIO_MODE, + HEATER_SMART_MODE, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("heater_mock") +async def test_setup_heater( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test climate platform setup for heater.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("preset_mode", "heater_mode"), + [ + (PRESET_NONE, HeaterMode.MANUAL), + (HEATER_BIO_MODE, HeaterMode.BIO), + (HEATER_SMART_MODE, HeaterMode.SMART), + ], +) +async def test_set_preset_mode( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + mock_config_entry: MockConfigEntry, + preset_mode: str, + heater_mode: HeaterMode, +) -> None: + """Test setting a preset mode.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_operation_mode.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + heater_mock.set_operation_mode.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + heater_mock.set_operation_mode.assert_awaited_with(heater_mode) + + +async def test_set_temperature( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting a preset mode.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_target_temperature.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + blocking=True, + ) + + heater_mock.set_target_temperature.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + blocking=True, + ) + + heater_mock.set_target_temperature.assert_awaited_with(26.0) + + +@pytest.mark.parametrize( + ("hvac_mode", "active"), [(HVACMode.AUTO, True), (HVACMode.OFF, False)] +) +async def test_set_hvac_mode( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + active: bool, +) -> None: + """Test setting a preset mode.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_active.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + heater_mock.set_active.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + heater_mock.set_active.assert_awaited_with(active=active) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + heater_mock: MagicMock, +) -> None: + """Test the climate state update.""" + heater_mock.temperature_unit = HeaterUnit.FAHRENHEIT + heater_mock.is_heating = False + heater_mock.operation_mode = HeaterMode.BIO + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + assert (state := hass.states.get("climate.mock_heater_none")) + + assert state.attributes["hvac_action"] == HVACAction.IDLE + assert state.attributes["preset_mode"] == HEATER_BIO_MODE + + heater_mock.is_active = False + heater_mock.operation_mode = HeaterMode.SMART + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("climate.mock_heater_none")) + assert state.state == HVACMode.OFF + assert state.attributes["preset_mode"] == HEATER_SMART_MODE diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index bb3304ec66c..a85eb16a986 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ComponentSetup, YieldFixture diff --git a/tests/components/elmax/test_alarm_control_panel.py b/tests/components/elmax/test_alarm_control_panel.py index 76dc8845662..88fc0a33c51 100644 --- a/tests/components/elmax/test_alarm_control_panel.py +++ b/tests/components/elmax/test_alarm_control_panel.py @@ -5,7 +5,7 @@ from unittest.mock import patch from syrupy import SnapshotAssertion -from homeassistant.components.elmax import POLLING_SECONDS +from homeassistant.components.elmax.const import POLLING_SECONDS from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 8a340d5e2dd..97dcc782096 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -58,7 +58,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType from tests.common import ( diff --git a/tests/components/energenie_power_sockets/conftest.py b/tests/components/energenie_power_sockets/conftest.py index c142e436fd3..d0301034cf8 100644 --- a/tests/components/energenie_power_sockets/conftest.py +++ b/tests/components/energenie_power_sockets/conftest.py @@ -44,7 +44,7 @@ def get_pyegps_device_mock() -> MagicMock: fkObj = FakePowerStrip( devId=DEMO_CONFIG_DATA[CONF_DEVICE_API_ID], number_of_sockets=4 ) - fkObj.release = lambda: True + fkObj.release = lambda: None fkObj._status = [0, 1, 0, 1] usb_device_mock = MagicMock(wraps=fkObj) diff --git a/tests/components/energenie_power_sockets/test_init.py b/tests/components/energenie_power_sockets/test_init.py index 4e2fe51665b..a11cef319b2 100644 --- a/tests/components/energenie_power_sockets/test_init.py +++ b/tests/components/energenie_power_sockets/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock from pyegps.exceptions import UsbError -from homeassistant.components.energenie_power_sockets.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,13 +23,11 @@ async def test_load_unload_entry( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert DOMAIN not in hass.data async def test_device_not_found_on_load_entry( diff --git a/tests/components/energenie_power_sockets/test_switch.py b/tests/components/energenie_power_sockets/test_switch.py index 4cd2bd60028..27f13390a83 100644 --- a/tests/components/energenie_power_sockets/test_switch.py +++ b/tests/components/energenie_power_sockets/test_switch.py @@ -6,7 +6,6 @@ from pyegps.exceptions import EgpsException import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.energenie_power_sockets.const import DOMAIN from homeassistant.components.homeassistant import ( DOMAIN as HOME_ASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -118,7 +117,6 @@ async def test_switch_setup( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] state = hass.states.get(f"switch.{entity_name}") assert state == snapshot diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index a27451b853d..a438842f8a5 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done diff --git a/tests/components/enocean/test_config_flow.py b/tests/components/enocean/test_config_flow.py index 96c0843906f..fb5b1de19d8 100644 --- a/tests/components/enocean/test_config_flow.py +++ b/tests/components/enocean/test_config_flow.py @@ -32,7 +32,7 @@ async def test_user_flow_cannot_create_multiple_instances(hass: HomeAssistant) - async def test_user_flow_with_detected_dongle(hass: HomeAssistant) -> None: - """Test the user flow with a detected ENOcean dongle.""" + """Test the user flow with a detected EnOcean dongle.""" FAKE_DONGLE_PATH = "/fake/dongle" with patch(DONGLE_DETECT_METHOD, Mock(return_value=[FAKE_DONGLE_PATH])): @@ -42,13 +42,13 @@ async def test_user_flow_with_detected_dongle(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "detect" - devices = result["data_schema"].schema.get("device").container + devices = result["data_schema"].schema.get(CONF_DEVICE).config.get("options") assert FAKE_DONGLE_PATH in devices assert EnOceanFlowHandler.MANUAL_PATH_VALUE in devices async def test_user_flow_with_no_detected_dongle(hass: HomeAssistant) -> None: - """Test the user flow with a detected ENOcean dongle.""" + """Test the user flow with a detected EnOcean dongle.""" with patch(DONGLE_DETECT_METHOD, Mock(return_value=[])): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/enphase_envoy/test_number.py b/tests/components/enphase_envoy/test_number.py index dbf711cacaa..7f9293eef7c 100644 --- a/tests/components/enphase_envoy/test_number.py +++ b/tests/components/enphase_envoy/test_number.py @@ -21,9 +21,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", ["envoy_metered_batt_relay", "envoy_eu_batt"], - indirect=["mock_envoy"], + indirect=True, ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number( @@ -40,14 +40,14 @@ async def test_number( @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", [ "envoy", "envoy_1p_metered", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", ], - indirect=["mock_envoy"], + indirect=True, ) async def test_no_number( hass: HomeAssistant, @@ -62,10 +62,10 @@ async def test_no_number( @pytest.mark.parametrize( - ("mock_envoy", "use_serial"), + ("mock_envoy", "use_serial", "expected_value", "test_value"), [ - ("envoy_metered_batt_relay", "enpower_654321"), - ("envoy_eu_batt", "envoy_1234"), + ("envoy_metered_batt_relay", "enpower_654321", 15.0, 30.0), + ("envoy_eu_batt", "envoy_1234", 0.0, 80.0), ], indirect=["mock_envoy"], ) @@ -74,6 +74,8 @@ async def test_number_operation_storage( mock_envoy: AsyncMock, config_entry: MockConfigEntry, use_serial: bool, + expected_value: float, + test_value: float, ) -> None: """Test enphase_envoy number storage entities operation.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): @@ -82,10 +84,8 @@ async def test_number_operation_storage( test_entity = f"{Platform.NUMBER}.{use_serial}_reserve_battery_level" assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.tariff.storage_settings.reserved_soc == float( - entity_state.state - ) - test_value = 30.0 + assert float(entity_state.state) == expected_value + await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -99,13 +99,27 @@ async def test_number_operation_storage( mock_envoy.set_reserve_soc.assert_awaited_once_with(test_value) +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("relay", "target", "expected_value", "test_value", "test_field"), + [ + ("NC1", "cutoff_battery_level", 25.0, 15.0, "soc_low"), + ("NC1", "restore_battery_level", 70.0, 75.0, "soc_high"), + ("NC2", "cutoff_battery_level", 30.0, 25.0, "soc_low"), + ("NC2", "restore_battery_level", 70.0, 80.0, "soc_high"), + ("NC3", "cutoff_battery_level", 30.0, 45.0, "soc_low"), + ("NC3", "restore_battery_level", 70.0, 90.0, "soc_high"), + ], ) async def test_number_operation_relays( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + relay: str, + target: str, + expected_value: float, + test_value: float, + test_field: str, ) -> None: """Test enphase_envoy number relay entities operation.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): @@ -113,48 +127,24 @@ async def test_number_operation_relays( entity_base = f"{Platform.NUMBER}." - for counter, (contact_id, dry_contact) in enumerate( - mock_envoy.data.dry_contact_settings.items() - ): - name = dry_contact.load_name.lower().replace(" ", "_") - test_entity = f"{entity_base}{name}_cutoff_battery_level" - assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.dry_contact_settings[contact_id].soc_low == float( - entity_state.state - ) - test_value = 10.0 + counter - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: test_entity, - ATTR_VALUE: test_value, - }, - blocking=True, - ) + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) - mock_envoy.update_dry_contact.assert_awaited_once_with( - {"id": contact_id, "soc_low": test_value} - ) - mock_envoy.update_dry_contact.reset_mock() + test_entity = f"{entity_base}{name}_{target}" - test_entity = f"{entity_base}{name}_restore_battery_level" - assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.dry_contact_settings[contact_id].soc_high == float( - entity_state.state - ) - test_value = 80.0 - counter - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: test_entity, - ATTR_VALUE: test_value, - }, - blocking=True, - ) + assert (entity_state := hass.states.get(test_entity)) + assert float(entity_state.state) == expected_value - mock_envoy.update_dry_contact.assert_awaited_once_with( - {"id": contact_id, "soc_high": test_value} - ) - mock_envoy.update_dry_contact.reset_mock() + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: test_entity, + ATTR_VALUE: test_value, + }, + blocking=True, + ) + + mock_envoy.update_dry_contact.assert_awaited_once_with( + {"id": relay, test_field: int(test_value)} + ) diff --git a/tests/components/enphase_envoy/test_switch.py b/tests/components/enphase_envoy/test_switch.py index f30cba4d201..d15c0ad740f 100644 --- a/tests/components/enphase_envoy/test_switch.py +++ b/tests/components/enphase_envoy/test_switch.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -16,6 +17,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -112,6 +114,46 @@ async def test_switch_grid_operation( mock_envoy.go_off_grid.reset_mock() +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) +async def test_switch_grid_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test switch platform operation for grid switches when error occurs.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + sn = mock_envoy.data.enpower.serial_number + test_entity = f"{Platform.SWITCH}.enpower_{sn}_grid_enabled" + + mock_envoy.go_off_grid.side_effect = EnvoyError("Test") + mock_envoy.go_on_grid.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + # test grid status switch operation + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "use_serial"), [ @@ -165,6 +207,53 @@ async def test_switch_charge_from_grid_operation( mock_envoy.disable_charge_from_grid.reset_mock() +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +async def test_switch_charge_from_grid_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, +) -> None: + """Test switch platform operation for charge from grid switches.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SWITCH}.{use_serial}_charge_from_grid" + + mock_envoy.disable_charge_from_grid.side_effect = EnvoyError("Test") + mock_envoy.enable_charge_from_grid.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + # test grid status switch operation + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "entity_states"), [ @@ -232,3 +321,51 @@ async def test_switch_relay_operation( assert mock_envoy.close_dry_contact.await_count == close_count mock_envoy.open_dry_contact.reset_mock() mock_envoy.close_dry_contact.reset_mock() + + +@pytest.mark.parametrize( + ("mock_envoy", "relay"), + [("envoy_metered_batt_relay", "NC1")], + indirect=["mock_envoy"], +) +async def test_switch_relay_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + relay: str, +) -> None: + """Test enphase_envoy switch relay entities operation.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + entity_base = f"{Platform.SWITCH}." + + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"{entity_base}{name}" + + mock_envoy.close_dry_contact.side_effect = EnvoyError("Test") + mock_envoy.open_dry_contact.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 5ca333df1e2..30535236970 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -48,8 +48,11 @@ from homeassistant.components.select import ( ) from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, intent as intent_helper -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + intent as intent_helper, +) from homeassistant.helpers.entity_component import EntityComponent from .conftest import MockESPHomeDevice diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 42b7e72a06e..a425b730771 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -38,7 +38,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index 49a854016ea..9b5fe6ad62d 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -25,7 +25,7 @@ SETUP_FAILED_ANTICIPATED = ( SETUP_FAILED_UNEXPECTED = ( "homeassistant.setup", logging.ERROR, - "Error during setup of component evohome", + "Error during setup of component evohome: ", ) AUTHENTICATION_FAILED = ( "homeassistant.components.evohome.helpers", diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index 4cc21078333..b3597352487 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -15,7 +15,7 @@ from homeassistant.components.evohome import ( dt_aware_to_naive, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import setup_evohome from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME diff --git a/tests/components/facebook/test_notify.py b/tests/components/facebook/test_notify.py index 77ae544646d..db9cd86e086 100644 --- a/tests/components/facebook/test_notify.py +++ b/tests/components/facebook/test_notify.py @@ -5,7 +5,7 @@ from http import HTTPStatus import pytest import requests_mock -import homeassistant.components.facebook.notify as fb +from homeassistant.components.facebook import notify as fb from homeassistant.core import HomeAssistant diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index f4673636637..bef44c92f34 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 90061ec60a1..0ab7686a68b 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -12,7 +12,7 @@ from homeassistant.components.fan import ( NotValidPresetModeError, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import MockFan diff --git a/tests/components/feedreader/test_event.py b/tests/components/feedreader/test_event.py index 32f8ecb8080..8f5f3870bfe 100644 --- a/tests/components/feedreader/test_event.py +++ b/tests/components/feedreader/test_event.py @@ -13,7 +13,7 @@ from homeassistant.components.feedreader.event import ( ATTR_TITLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import create_mock_entry from .const import VALID_CONFIG_DEFAULT diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 9a2575bf591..5d2ac1a4406 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -14,7 +14,7 @@ from homeassistant.components.feedreader.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_setup_config_entry, create_mock_entry from .const import ( diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index e7cb85a9cfc..44b9d61efec 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -12,7 +12,7 @@ from homeassistant.components.file import DOMAIN from homeassistant.components.notify import ATTR_TITLE_DEFAULT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/filter/conftest.py b/tests/components/filter/conftest.py new file mode 100644 index 00000000000..a576a2edb37 --- /dev/null +++ b/tests/components/filter/conftest.py @@ -0,0 +1,93 @@ +"""Fixtures for the Filter integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.filter.const import ( + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_WINDOW_SIZE, + DEFAULT_FILTER_RADIUS, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_OUTLIER, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant, State +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="values") +def values_fixture() -> list[State]: + """Fixture for a list of test States.""" + values = [] + raw_values = [20, 19, 18, 21, 22, 0] + timestamp = dt_util.utcnow() + for val in raw_values: + values.append(State("sensor.test_monitored", str(val), last_updated=timestamp)) + timestamp += timedelta(minutes=1) + return values + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Automatically patch setup_entry.""" + with patch( + "homeassistant.components.filter.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="get_config") +async def get_config_to_integration_load() -> dict[str, Any]: + """Return configuration. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_config", [{...}]) + """ + return { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: DEFAULT_WINDOW_SIZE, + CONF_FILTER_RADIUS: DEFAULT_FILTER_RADIUS, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, get_config: dict[str, Any], values: list[State] +) -> MockConfigEntry: + """Set up the Filter integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + options=get_config, + entry_id="1", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + for value in values: + hass.states.async_set(get_config["entity_id"], value.state) + await hass.async_block_till_done() + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/filter/test_config_flow.py b/tests/components/filter/test_config_flow.py new file mode 100644 index 00000000000..d4a7f7a854f --- /dev/null +++ b/tests/components/filter/test_config_flow.py @@ -0,0 +1,227 @@ +"""Test the Filter config flow.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.filter.const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + TIME_SMA_LAST, +) +from homeassistant.components.recorder import Recorder +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entry_config", "options", "result_options"), + [ + ( + {CONF_FILTER_NAME: FILTER_NAME_OUTLIER}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + CONF_FILTER_RADIUS: 2.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: 1, + CONF_FILTER_RADIUS: 2.0, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_LOWPASS}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + CONF_FILTER_TIME_CONSTANT: 10.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_LOWPASS, + CONF_FILTER_WINDOW_SIZE: 1, + CONF_FILTER_TIME_CONSTANT: 10, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_RANGE}, + { + CONF_FILTER_LOWER_BOUND: 1.0, + CONF_FILTER_UPPER_BOUND: 10.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_RANGE, + CONF_FILTER_LOWER_BOUND: 1.0, + CONF_FILTER_UPPER_BOUND: 10.0, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_TIME_SMA}, + { + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + { + CONF_FILTER_NAME: FILTER_NAME_TIME_SMA, + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_THROTTLE}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_THROTTLE, + CONF_FILTER_WINDOW_SIZE: 1, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_TIME_THROTTLE}, + { + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + { + CONF_FILTER_NAME: FILTER_NAME_TIME_THROTTLE, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + ), + ], +) +async def test_form( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + entry_config: dict[str, Any], + options: dict[str, Any], + result_options: dict[str, Any], +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + **entry_config, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_FILTER_PRECISION: DEFAULT_PRECISION, **options}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + **result_options, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test options flow.""" + + result = await hass.config_entries.options.async_init(loaded_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "outlier" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_FILTER_WINDOW_SIZE: 2.0, + CONF_FILTER_RADIUS: 3.0, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: 2, + CONF_FILTER_RADIUS: 3.0, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 2 + + state = hass.states.get("sensor.filtered_sensor") + assert state is not None + + +async def test_entry_already_exist( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test abort when entry already exist.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FILTER_WINDOW_SIZE: DEFAULT_WINDOW_SIZE, + CONF_FILTER_RADIUS: DEFAULT_FILTER_RADIUS, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/filter/test_init.py b/tests/components/filter/test_init.py new file mode 100644 index 00000000000..a5d5cf84a67 --- /dev/null +++ b/tests/components/filter/test_init.py @@ -0,0 +1,20 @@ +"""Test Filter component setup process.""" + +from __future__ import annotations + +from homeassistant.components.recorder import Recorder +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test unload an entry.""" + + assert loaded_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(loaded_entry.entry_id) + await hass.async_block_till_done() + assert loaded_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index a3e0e58908a..22db1c3cec2 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -6,8 +6,18 @@ from unittest.mock import patch import pytest from homeassistant import config as hass_config -from homeassistant.components.filter.sensor import ( +from homeassistant.components.filter.const import ( + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_NAME, + DEFAULT_PRECISION, DOMAIN, + FILTER_NAME_TIME_SMA, + TIME_SMA_LAST, +) +from homeassistant.components.filter.sensor import ( LowPassFilter, OutlierFilter, RangeFilter, @@ -24,6 +34,8 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, + CONF_NAME, SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -32,9 +44,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, get_fixture_path +from tests.common import MockConfigEntry, assert_setup_component, get_fixture_path @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -97,6 +109,41 @@ async def test_chain( assert state.state == "18.05" +async def test_from_config_entry( + recorder_mock: Recorder, + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test if filter works loaded from config entry.""" + + state = hass.states.get("sensor.filtered_sensor") + assert state.state == "22.0" + + +@pytest.mark.parametrize( + "get_config", + [ + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_TIME_SMA, + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + ], +) +async def test_from_config_entry_duration( + recorder_mock: Recorder, + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test if filter works loaded from config entry with duration.""" + + state = hass.states.get("sensor.filtered_sensor") + assert state.state == "20.0" + + @pytest.mark.parametrize("missing", [True, False]) async def test_chain_history( recorder_mock: Recorder, diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index 7b0546f60ea..79ee84bdc14 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -6,7 +6,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index f7dc30db240..e1bd07cdfd7 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( assert_setup_component, diff --git a/tests/components/flux_led/test_number.py b/tests/components/flux_led/test_number.py index 2ed0d34989f..8dd8196a2db 100644 --- a/tests/components/flux_led/test_number.py +++ b/tests/components/flux_led/test_number.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( DEFAULT_ENTRY_TITLE, diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 7dec640b898..1b10ddb8fc1 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import MOCK_USER_DATA diff --git a/tests/components/fritz/test_services.py b/tests/components/fritz/test_services.py new file mode 100644 index 00000000000..d7b85cbc448 --- /dev/null +++ b/tests/components/fritz/test_services.py @@ -0,0 +1,134 @@ +"""Tests for Fritz!Tools services.""" + +from unittest.mock import patch + +from fritzconnection.core.exceptions import FritzConnectionException, FritzServiceError +import pytest + +from homeassistant.components.fritz.const import DOMAIN +from homeassistant.components.fritz.services import SERVICE_SET_GUEST_WIFI_PW +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from .const import MOCK_USER_DATA + +from tests.common import MockConfigEntry + + +async def test_setup_services(hass: HomeAssistant) -> None: + """Test setup of Fritz!Tools services.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + services = hass.services.async_services_for_domain(DOMAIN) + assert services + assert SERVICE_SET_GUEST_WIFI_PW in services + + +async def test_service_set_guest_wifi_password( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password" + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + + +async def test_service_set_guest_wifi_password_unknown_parameter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password with unknown parameter.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password", + side_effect=FritzServiceError("boom"), + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + assert "HomeAssistantError: Action or parameter unknown" in caplog.text + + +async def test_service_set_guest_wifi_password_service_not_supported( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password with connection error.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password", + side_effect=FritzConnectionException("boom"), + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + assert "HomeAssistantError: Action not supported" in caplog.text + + +async def test_service_set_guest_wifi_password_unloaded( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test service set_guest_wifi_password.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password" + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": "12345678"} + ) + assert not mock_async_trigger_set_guest_password.called + assert ( + 'ServiceValidationError: Failed to perform action "set_guest_wifi_password". Config entry for target not found' + in caplog.text + ) diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index f4cc1b2e2ca..594ed14a7d1 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceBinarySensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 913f828efbc..0053a8d3446 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -12,7 +12,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzEntityBaseMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 29f5742216f..87e6d36e3b6 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -44,7 +44,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceClimateMock, @@ -273,20 +273,20 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: @pytest.mark.parametrize( ("service_data", "expected_call_args"), [ - ({ATTR_TEMPERATURE: 23}, [call(23)]), + ({ATTR_TEMPERATURE: 23}, [call(23, True)]), ( { ATTR_HVAC_MODE: HVACMode.OFF, ATTR_TEMPERATURE: 23, }, - [call(0)], + [call(0, True)], ), ( { ATTR_HVAC_MODE: HVACMode.HEAT, ATTR_TEMPERATURE: 23, }, - [call(23)], + [call(23, True)], ), ], ) @@ -316,14 +316,14 @@ async def test_set_temperature( ("service_data", "target_temperature", "current_preset", "expected_call_args"), [ # mode off always sets target temperature to 0 - ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0, True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0, True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0, True)]), # mode heat sets target temperature based on current scheduled preset, # when not already in mode heat - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22, True)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16, True)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22, True)]), # mode heat does not set target temperature, when already in mode heat ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_COMFORT, []), ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_ECO, []), @@ -380,7 +380,7 @@ async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, True, ) - assert device.set_target_temperature.call_args_list == [call(22)] + assert device.set_target_temperature.call_args_list == [call(22, True)] async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: @@ -396,7 +396,7 @@ async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, True, ) - assert device.set_target_temperature.call_args_list == [call(16)] + assert device.set_target_temperature.call_args_list == [call(16, True)] async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index f26e65fc28a..535306e4ef2 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -20,7 +20,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceCoverMock, @@ -99,7 +99,7 @@ async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 50}, True, ) - assert device.set_level_percentage.call_args_list == [call(50)] + assert device.set_level_percentage.call_args_list == [call(50, True)] async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 84fafe25521..fe8bb32066e 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -3,7 +3,6 @@ from datetime import timedelta from unittest.mock import Mock, call -import pytest from requests.exceptions import HTTPError from homeassistant.components.fritzbox.const import ( @@ -31,7 +30,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceLightMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG @@ -155,8 +154,8 @@ async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color_temp.call_count == 1 - assert device.set_color_temp.call_args_list == [call(3000)] - assert device.set_level.call_args_list == [call(100)] + assert device.set_color_temp.call_args_list == [call(3000, 0, True)] + assert device.set_level.call_args_list == [call(100, True)] async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: @@ -166,6 +165,7 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: device.get_colors.return_value = { "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } + device.fullcolorsupport = True assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -178,13 +178,14 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_unmapped_color.call_count == 1 - assert device.set_level.call_args_list == [call(100)] + assert device.set_color.call_count == 0 + assert device.set_level.call_args_list == [call(100, True)] assert device.set_unmapped_color.call_args_list == [ - call((100, round(70 * 255.0 / 100.0))) + call((100, round(70 * 255.0 / 100.0)), 0, True) ] -async def test_turn_on_color_unsupported_api_method( +async def test_turn_on_color_no_fullcolorsupport( hass: HomeAssistant, fritz: Mock ) -> None: """Test turn device on in mapped color mode if unmapped is not supported.""" @@ -193,16 +194,11 @@ async def test_turn_on_color_unsupported_api_method( device.get_colors.return_value = { "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } + device.fullcolorsupport = False assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - # test fallback to `setcolor` - error = HTTPError("Bad Request") - error.response = Mock() - error.response.status_code = 400 - device.set_unmapped_color.side_effect = error - await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -212,18 +208,9 @@ async def test_turn_on_color_unsupported_api_method( assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color.call_count == 1 - assert device.set_level.call_args_list == [call(100)] - assert device.set_color.call_args_list == [call((100, 70))] - - # test for unknown error - error.response.status_code = 500 - with pytest.raises(HTTPError, match="Bad Request"): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_HS_COLOR: (100, 70)}, - True, - ) + assert device.set_unmapped_color.call_count == 0 + assert device.set_level.call_args_list == [call(100, True)] + assert device.set_color.call_args_list == [call((100, 70), 0, True)] async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 0da040bbb5b..67b2c3e8ab6 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceClimateMock, diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index e394ccbc7f3..511725c663f 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -32,7 +32,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceSwitchMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fyta/conftest.py b/tests/components/fyta/conftest.py index 299b96be959..92abab7091a 100644 --- a/tests/components/fyta/conftest.py +++ b/tests/components/fyta/conftest.py @@ -81,3 +81,13 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.fyta.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_getrandbits(): + """Mock image access token which normally is randomized.""" + with patch( + "homeassistant.components.image.SystemRandom.getrandbits", + return_value=1, + ): + yield diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index ca5662714a0..21e1fcfb0ab 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -19,8 +19,8 @@ "online": true, "ph": null, "plant_id": 0, - "plant_origin_path": "", - "plant_thumb_path": "", + "plant_origin_path": "http://www.plant_picture.com/picture", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": false, "salinity": 1, "salinity_status": 4, diff --git a/tests/components/fyta/fixtures/plant_status1_update.json b/tests/components/fyta/fixtures/plant_status1_update.json new file mode 100644 index 00000000000..98a4c6a9d91 --- /dev/null +++ b/tests/components/fyta/fixtures/plant_status1_update.json @@ -0,0 +1,30 @@ +{ + "battery_level": 80, + "fertilisation": { + "was_repotted": true + }, + "low_battery": false, + "last_updated": "2023-01-10 10:10:00", + "light": 2, + "light_status": 3, + "nickname": "Gummibaum", + "nutrients_status": 3, + "moisture": 61, + "moisture_status": 3, + "sensor_available": true, + "sensor_id": "FD:1D:B7:E3:D0:E2", + "sensor_update_available": true, + "sw_version": "1.0", + "status": 1, + "online": true, + "ph": null, + "plant_id": 0, + "plant_origin_path": "http://www.plant_picture.com/picture1", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", + "is_productive_plant": false, + "salinity": 1, + "salinity_status": 4, + "scientific_name": "Ficus elastica", + "temperature": 25.2, + "temperature_status": 3 +} diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json index 2bedd196fe1..4bb4e0b81a7 100644 --- a/tests/components/fyta/fixtures/plant_status3.json +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -19,8 +19,8 @@ "online": true, "ph": 7, "plant_id": 0, - "plant_origin_path": "", - "plant_thumb_path": "", + "plant_origin_path": "http://www.plant_picture.com/picture", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": true, "salinity": 1, "salinity_status": 4, diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index b4da0238db0..a252e81952c 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -43,8 +43,8 @@ 'online': True, 'ph': None, 'plant_id': 0, - 'plant_origin_path': '', - 'plant_thumb_path': '', + 'plant_origin_path': 'http://www.plant_picture.com/picture', + 'plant_thumb_path': 'http://www.plant_picture.com/picture_thumb', 'productive_plant': False, 'repotted': True, 'salinity': 1.0, diff --git a/tests/components/fyta/snapshots/test_image.ambr b/tests/components/fyta/snapshots/test_image.ambr new file mode 100644 index 00000000000..95e25e0a4d7 --- /dev/null +++ b/tests/components/fyta/snapshots/test_image.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[image.gummibaum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.gummibaum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-plant_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.gummibaum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.gummibaum?token=1', + 'friendly_name': 'Gummibaum', + }), + 'context': , + 'entity_id': 'image.gummibaum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[image.kakaobaum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.kakaobaum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-plant_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.kakaobaum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.kakaobaum?token=1', + 'friendly_name': 'Kakaobaum', + }), + 'context': , + 'entity_id': 'image.kakaobaum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fyta/test_image.py b/tests/components/fyta/test_image.py new file mode 100644 index 00000000000..4feb125bd15 --- /dev/null +++ b/tests/components/fyta/test_image.py @@ -0,0 +1,129 @@ +"""Test the Home Assistant fyta sensor module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError +from fyta_cli.fyta_models import Plant +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN +from homeassistant.components.image import ImageEntity +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) + + +async def test_all_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all entities.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + assert len(hass.states.async_all("image")) == 2 + + +@pytest.mark.parametrize( + "exception", + [ + FytaConnectionError, + FytaPlantError, + ], +) +async def test_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + mock_fyta_connector.update_all_plants.side_effect = exception + + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("image.gummibaum").state == STATE_UNAVAILABLE + + +async def test_add_remove_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entities are added and old are removed.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + assert hass.states.get("image.gummibaum") is not None + + plants: dict[int, Plant] = { + 0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("image.kakaobaum") is None + assert hass.states.get("image.tomatenpflanze") is not None + + +async def test_update_image( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entity picture is updated.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + image_entity: ImageEntity = hass.data["domain_entities"]["image"]["image.gummibaum"] + + assert image_entity.image_url == "http://www.plant_picture.com/picture" + + plants: dict[int, Plant] = { + 0: Plant.from_dict( + load_json_object_fixture("plant_status1_update.json", FYTA_DOMAIN) + ), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert image_entity.image_url == "http://www.plant_picture.com/picture1" diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index 4ea28bd8fd3..68e2d061259 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index 87b66295006..01609cf485e 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _generate_mock_feed_entry diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index 33a8a0f37bd..3acb50fa38d 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -7,6 +7,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import input_boolean, switch from homeassistant.components.generic_hygrostat import ( DOMAIN as GENERIC_HYDROSTAT_DOMAIN, @@ -28,7 +29,6 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) -import homeassistant.core as ha from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, @@ -40,7 +40,7 @@ from homeassistant.core import ( from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import StateType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 8cbbdbb49d4..7e2e92f025b 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -7,7 +7,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol -from homeassistant import config as hass_config +from homeassistant import config as hass_config, core as ha from homeassistant.components import input_boolean, switch from homeassistant.components.climate import ( ATTR_PRESET_MODE, @@ -35,7 +35,6 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -import homeassistant.core as ha from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, diff --git a/tests/components/geo_rss_events/test_sensor.py b/tests/components/geo_rss_events/test_sensor.py index d19262c3339..3b6ef8a0642 100644 --- a/tests/components/geo_rss_events/test_sensor.py +++ b/tests/components/geo_rss_events/test_sensor.py @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import sensor -import homeassistant.components.geo_rss_events.sensor as geo_rss_events +from homeassistant.components.geo_rss_events import sensor as geo_rss_events from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, @@ -15,7 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index 163bca775c9..fd8ba81fca7 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/geonetnz_quakes/test_sensor.py b/tests/components/geonetnz_quakes/test_sensor.py index 82143baa374..2daeab9e7ef 100644 --- a/tests/components/geonetnz_quakes/test_sensor.py +++ b/tests/components/geonetnz_quakes/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _generate_mock_feed_entry diff --git a/tests/components/geonetnz_volcano/test_sensor.py b/tests/components/geonetnz_volcano/test_sensor.py index d6ebbcd6582..a79d8512df6 100644 --- a/tests/components/geonetnz_volcano/test_sensor.py +++ b/tests/components/geonetnz_volcano/test_sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/goalzero/test_init.py b/tests/components/goalzero/test_init.py index 1d44c7e808e..4817be1ce35 100644 --- a/tests/components/goalzero/test_init.py +++ b/tests/components/goalzero/test_init.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import CONF_DATA, async_init_integration, create_entry, create_mocked_yeti diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 305f30d99d4..3d10e753714 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.helpers.template import DATE_STR_FORMAT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( CALENDAR_ID, diff --git a/tests/components/google_drive/__init__.py b/tests/components/google_drive/__init__.py new file mode 100644 index 00000000000..7a55f70a3d6 --- /dev/null +++ b/tests/components/google_drive/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Drive integration.""" diff --git a/tests/components/google_drive/conftest.py b/tests/components/google_drive/conftest.py new file mode 100644 index 00000000000..479412ddbe2 --- /dev/null +++ b/tests/components/google_drive/conftest.py @@ -0,0 +1,80 @@ +"""PyTest fixtures and test helpers.""" + +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +HA_UUID = "0a123c" +TEST_AGENT_ID = "google_drive.testuser_domain_com" +TEST_USER_EMAIL = "testuser@domain.com" +CONFIG_ENTRY_TITLE = "Google Drive entry title" + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +def mock_api() -> Generator[MagicMock]: + """Return a mocked GoogleDriveApi.""" + with patch( + "homeassistant.components.google_drive.api.GoogleDriveApi" + ) as mock_api_cl: + mock_api = mock_api_cl.return_value + yield mock_api + + +@pytest.fixture(autouse=True) +def mock_instance_id() -> Generator[AsyncMock]: + """Mock instance_id.""" + with patch( + "homeassistant.components.google_drive.config_flow.instance_id.async_get", + return_value=HA_UUID, + ): + yield + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int) -> MockConfigEntry: + """Fixture for MockConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + title=CONFIG_ENTRY_TITLE, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": "https://www.googleapis.com/auth/drive.file", + }, + }, + ) diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr new file mode 100644 index 00000000000..0832682b74d --- /dev/null +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -0,0 +1,237 @@ +# serializer version: 1 +# name: test_agents_delete + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and properties has { key='backup_id' and value='test-backup' }", + }), + }), + ), + tuple( + 'delete_file', + tuple( + 'backup-file-id', + ), + dict({ + }), + ), + ]) +# --- +# name: test_agents_download + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and properties has { key='backup_id' and value='test-backup' }", + }), + }), + ), + tuple( + 'get_file_content', + tuple( + 'backup-file-id', + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- +# name: test_agents_list_backups + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + ]) +# --- +# name: test_agents_upload + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'upload_file', + tuple( + dict({ + 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', + 'name': 'Test 2025-01-01T01:23:45.678Z.tar', + 'parents': list([ + 'HA folder ID', + ]), + 'properties': dict({ + 'backup_id': 'test-backup', + 'home_assistant': 'backup', + 'instance_id': '0a123c', + }), + }), + "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- +# name: test_agents_upload_create_folder_if_missing + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'create_file', + tuple( + ), + dict({ + 'json': dict({ + 'mimeType': 'application/vnd.google-apps.folder', + 'name': 'Home Assistant', + 'properties': dict({ + 'home_assistant': 'root', + 'instance_id': '0a123c', + }), + }), + 'params': dict({ + 'fields': 'id,name', + }), + }), + ), + tuple( + 'upload_file', + tuple( + dict({ + 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', + 'name': 'Test 2025-01-01T01:23:45.678Z.tar', + 'parents': list([ + 'new folder id', + ]), + 'properties': dict({ + 'backup_id': 'test-backup', + 'home_assistant': 'backup', + 'instance_id': '0a123c', + }), + }), + "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- diff --git a/tests/components/google_drive/snapshots/test_config_flow.ambr b/tests/components/google_drive/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..68e5416c5ec --- /dev/null +++ b/tests/components/google_drive/snapshots/test_config_flow.ambr @@ -0,0 +1,44 @@ +# serializer version: 1 +# name: test_full_flow + list([ + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'user(emailAddress)', + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'create_file', + tuple( + ), + dict({ + 'json': dict({ + 'mimeType': 'application/vnd.google-apps.folder', + 'name': 'Home Assistant', + 'properties': dict({ + 'home_assistant': 'root', + 'instance_id': '0a123c', + }), + }), + 'params': dict({ + 'fields': 'id,name', + }), + }), + ), + ]) +# --- diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py new file mode 100644 index 00000000000..7e455ebb535 --- /dev/null +++ b/tests/components/google_drive/test_backup.py @@ -0,0 +1,459 @@ +"""Test the Google Drive backup platform.""" + +from io import StringIO +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientResponse +from google_drive_api.exceptions import GoogleDriveApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, +) +from homeassistant.components.google_drive import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import mock_stream +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +FOLDER_ID = "google-folder-id" +TEST_AGENT_BACKUP = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="test-backup", + database_included=True, + date="2025-01-01T01:23:45.678Z", + extra_metadata={ + "with_automatic_settings": False, + }, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=987, +) +TEST_AGENT_BACKUP_RESULT = { + "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "agents": {TEST_AGENT_ID: {"protected": False, "size": 987}}, + "backup_id": "test-backup", + "database_included": True, + "date": "2025-01-01T01:23:45.678Z", + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "failed_agent_ids": [], + "with_automatic_settings": None, +} + + +@pytest.fixture(autouse=True) +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """Set up Google Drive integration.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": TEST_AGENT_ID, "name": CONFIG_ENTRY_TITLE}, + ], + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local", "name": "local"}] + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent list backups.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [TEST_AGENT_BACKUP_RESULT] + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_list_backups_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent list backups fails.""" + mock_api.list_files = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backups"] == [] + assert response["result"]["agent_errors"] == { + TEST_AGENT_ID: "Failed to list backups: some error" + } + + +@pytest.mark.parametrize( + ("backup_id", "expected_result"), + [ + (TEST_AGENT_BACKUP.backup_id, TEST_AGENT_BACKUP_RESULT), + ("12345", None), + ], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + backup_id: str, + expected_result: dict[str, Any] | None, +) -> None: + """Test agent get backup.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == expected_result + + +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent download backup.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": [{"id": "backup-file-id"}]}, + ] + ) + mock_response = AsyncMock(spec=ClientResponse) + mock_response.content = mock_stream(b"backup data") + mock_api.get_file_content = AsyncMock(return_value=mock_response) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_download_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup fails.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": [{"id": "backup-file-id"}]}, + ] + ) + mock_response = AsyncMock(spec=ClientResponse) + mock_response.content = mock_stream(b"backup data") + mock_api.get_file_content = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 500 + content = await resp.content.read() + assert "Failed to download backup" in content.decode() + + +async def test_agents_download_file_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup raises error if not found.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": []}, + ] + ) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 500 + content = await resp.content.read() + assert "Backup not found" in content.decode() + + +async def test_agents_download_metadata_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup raises error if not found.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + + client = await hass_client() + backup_id = "1234" + assert backup_id != TEST_AGENT_BACKUP.backup_id + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 404 + assert await resp.content.read() == b"" + + +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent upload backup.""" + mock_api.upload_file = AsyncMock(return_value=None) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + + mock_api.upload_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_upload_create_folder_if_missing( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent upload backup creates folder if missing.""" + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": "new folder id", "name": "Home Assistant"} + ) + mock_api.upload_file = AsyncMock(return_value=None) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + + mock_api.create_file.assert_called_once() + mock_api.upload_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_upload_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, +) -> None: + """Test agent upload backup fails.""" + mock_api.upload_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert "Failed to upload backup: some error" in caplog.text + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent delete backup.""" + mock_api.list_files = AsyncMock(return_value={"files": [{"id": "backup-file-id"}]}) + mock_api.delete_file = AsyncMock(return_value=None) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + mock_api.delete_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_delete_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent delete backup fails.""" + mock_api.list_files = AsyncMock(return_value={"files": [{"id": "backup-file-id"}]}) + mock_api.delete_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {TEST_AGENT_ID: "Failed to delete backup: some error"} + } + + +async def test_agents_delete_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent delete backup not found.""" + mock_api.list_files = AsyncMock(return_value={"files": []}) + + client = await hass_ws_client(hass) + backup_id = "1234" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + mock_api.delete_file.assert_not_called() diff --git a/tests/components/google_drive/test_config_flow.py b/tests/components/google_drive/test_config_flow.py new file mode 100644 index 00000000000..10f73d53a66 --- /dev/null +++ b/tests/components/google_drive/test_config_flow.py @@ -0,0 +1,363 @@ +"""Test the Google Drive config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from google_drive_api.exceptions import GoogleDriveApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .conftest import CLIENT_ID, TEST_USER_EMAIL + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token" +FOLDER_ID = "google-folder-id" +FOLDER_NAME = "folder name" +TITLE = "Google Drive" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": FOLDER_ID, "name": FOLDER_NAME} + ) + + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_drive.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + assert len(aioclient_mock.mock_calls) == 1 + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == TITLE + assert result.get("description_placeholders") == { + "folder_name": FOLDER_NAME, + "url": f"https://drive.google.com/drive/folders/{FOLDER_ID}", + } + assert "result" in result + assert result.get("result").unique_id == TEST_USER_EMAIL + assert "token" in result.get("result").data + assert result.get("result").data["token"].get("access_token") == "mock-access-token" + assert ( + result.get("result").data["token"].get("refresh_token") == "mock-refresh-token" + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_create_folder_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test case where creating the folder fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "create_folder_failure" + assert result.get("description_placeholders") == {"message": "some error"} + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("exception", "expected_abort_reason", "expected_placeholders"), + [ + ( + GoogleDriveApiError("some error"), + "access_not_configured", + {"message": "some error"}, + ), + (Exception, "unknown", None), + ], + ids=["api_not_enabled", "general_exception"], +) +async def test_get_email_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + exception: Exception, + expected_abort_reason, + expected_placeholders, +) -> None: + """Test case where getting the email address fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock(side_effect=exception) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == expected_abort_reason + assert result.get("description_placeholders") == expected_placeholders + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ( + "new_email", + "expected_abort_reason", + "expected_placeholders", + "expected_access_token", + "expected_setup_calls", + ), + [ + (TEST_USER_EMAIL, "reauth_successful", None, "updated-access-token", 1), + ( + "other.user@domain.com", + "wrong_account", + {"email": TEST_USER_EMAIL}, + "mock-access-token", + 0, + ), + ], + ids=["reauth_successful", "wrong_account"], +) +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + new_email: str, + expected_abort_reason: str, + expected_placeholders: dict[str, str] | None, + expected_access_token: str, + expected_setup_calls: int, +) -> None: + """Test the reauthentication flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock(return_value={"user": {"emailAddress": new_email}}) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_drive.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == expected_setup_calls + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == expected_abort_reason + assert result.get("description_placeholders") == expected_placeholders + + assert config_entry.unique_id == TEST_USER_EMAIL + assert "token" in config_entry.data + + # Verify access token is refreshed + assert config_entry.data["token"].get("access_token") == expected_access_token + assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test already configured account.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" diff --git a/tests/components/google_drive/test_init.py b/tests/components/google_drive/test_init.py new file mode 100644 index 00000000000..8173e00fb54 --- /dev/null +++ b/tests/components/google_drive/test_init.py @@ -0,0 +1,164 @@ +"""Tests for Google Drive.""" + +from collections.abc import Awaitable, Callable, Coroutine +import http +import time +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from google_drive_api.exceptions import GoogleDriveApiError +import pytest + +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +type ComponentSetup = Callable[[], Awaitable[None]] + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> Callable[[], Coroutine[Any, Any, None]]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + async def func() -> None: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return func + + +async def test_setup_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test successful setup and unload.""" + # Setup looks up existing folder to make sure it still exists + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert entries[0].state is ConfigEntryState.NOT_LOADED + + +async def test_create_folder_if_missing( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test folder is created if missing.""" + # Setup looks up existing folder to make sure it still exists + # and creates it if missing + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": "new folder id", "name": "Home Assistant"} + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_api.list_files.assert_called_once() + mock_api.create_file.assert_called_once() + + +async def test_setup_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test setup error.""" + # Simulate failure looking up existing folder + mock_api.list_files = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test expired token is refreshed.""" + # Setup looks up existing folder to make sure it still exists + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert entries[0].data["token"]["access_token"] == "updated-access-token" + assert entries[0].data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + status=status, + ) + + await setup_integration() + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 65238c5212a..21458abb7c8 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -42,6 +42,10 @@ 'parts': 'Ok', 'role': 'model', }), + dict({ + 'parts': '1st user request', + 'role': 'user', + }), ]), }), ), @@ -102,6 +106,10 @@ 'parts': '1st model response', 'role': 'model', }), + dict({ + 'parts': '2nd user request', + 'role': 'user', + }), ]), }), ), @@ -150,6 +158,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': '1st user request', + 'role': 'user', + }), ]), }), ), @@ -202,6 +214,10 @@ 'parts': '1st model response', 'role': 'model', }), + dict({ + 'parts': '2nd user request', + 'role': 'user', + }), ]), }), ), @@ -250,6 +266,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -298,6 +318,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -347,6 +371,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -396,6 +424,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -482,6 +514,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'Please call the test function', + 'role': 'user', + }), ]), }), ), @@ -558,6 +594,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'Please call the test function', + 'role': 'user', + }), ]), }), ), diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index df0b11487d8..a87056275dc 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -208,6 +208,7 @@ async def test_function_call( chat_response = MagicMock() mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() + mock_part.text = "" mock_part.function_call = FunctionCall( name="test_tool", args={ @@ -284,8 +285,12 @@ async def test_function_call( ] # AGENT_DETAIL event contains the raw prompt passed to the model detail_event = trace_events[1] - assert "Answer in plain text" in detail_event["data"]["prompt"] - assert [t.name for t in detail_event["data"]["tools"]] == ["test_tool"] + assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] + assert [ + p.function_response.name + for p in detail_event["data"]["messages"][2]["content"].parts + if p.function_response + ] == ["test_tool"] @patch( @@ -315,6 +320,7 @@ async def test_function_call_without_parameters( chat_response = MagicMock() mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() + mock_part.text = "" mock_part.function_call = FunctionCall(name="test_tool", args={}) def tool_call( @@ -403,6 +409,7 @@ async def test_function_exception( chat_response = MagicMock() mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() + mock_part.text = "" mock_part.function_call = FunctionCall(name="test_tool", args={"param1": 1}) def tool_call( @@ -543,7 +550,7 @@ async def test_invalid_llm_api( assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "Error preparing LLM API: API invalid_llm_api not found" + "Error preparing LLM API" ) diff --git a/tests/components/google_mail/test_sensor.py b/tests/components/google_mail/test_sensor.py index 6f2f1a4ec32..e9dd2da85de 100644 --- a/tests/components/google_mail/test_sensor.py +++ b/tests/components/google_mail/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.google_mail.const import DOMAIN from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import SENSOR, TOKEN, ComponentSetup diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index 18d96e3a1c0..88adcbf6587 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -7,7 +7,7 @@ from unittest.mock import Mock, patch import requests_mock -import homeassistant.components.google_wifi.sensor as google_wifi +from homeassistant.components.google_wifi import sensor as google_wifi from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index ae2f0c74236..acfa1ba43f5 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -12,7 +12,7 @@ from homeassistant.components.gree.const import ( UPDATE_INTERVAL, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import async_setup_gree, build_device_mock diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index b1f622569bd..ab92b18cc91 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -38,7 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 91604d663b3..dbd74e95780 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -6,8 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config as hass_config -from homeassistant.components.group import DOMAIN, SERVICE_RELOAD -import homeassistant.components.group.light as group +from homeassistant.components.group import DOMAIN, SERVICE_RELOAD, light as group from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index de406cb251c..187991141e7 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -35,8 +35,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import get_fixture_path diff --git a/tests/components/hardware/test_websocket_api.py b/tests/components/hardware/test_websocket_api.py index 1379bdba120..64fcda02df4 100644 --- a/tests/components/hardware/test_websocket_api.py +++ b/tests/components/hardware/test_websocket_api.py @@ -10,7 +10,7 @@ import psutil_home_assistant as ha_psutil from homeassistant.components.hardware.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.typing import WebSocketGenerator diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 40ab253b7e6..9ba73ade1a3 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -13,6 +13,7 @@ from io import StringIO import os from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch +from uuid import UUID from aiohasupervisor.exceptions import ( SupervisorBadRequestError, @@ -21,6 +22,7 @@ from aiohasupervisor.exceptions import ( ) from aiohasupervisor.models import ( backups as supervisor_backups, + jobs as supervisor_jobs, mounts as supervisor_mounts, ) from aiohasupervisor.models.mounts import MountsInfo @@ -34,7 +36,12 @@ from homeassistant.components.backup import ( BackupAgentPlatformProtocol, Folder, ) -from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP +from homeassistant.components.hassio import DOMAIN +from homeassistant.components.hassio.backup import ( + LOCATION_CLOUD_BACKUP, + LOCATION_LOCAL, + RESTORE_JOB_ID_ENV, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -52,6 +59,11 @@ TEST_BACKUP = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location=None, + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={None}, name="Test", protected=False, @@ -76,6 +88,7 @@ TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( homeassistant_exclude_database=False, homeassistant="2024.12.0", location=TEST_BACKUP.location, + location_attributes=TEST_BACKUP.location_attributes, locations=TEST_BACKUP.locations, name=TEST_BACKUP.name, protected=TEST_BACKUP.protected, @@ -96,6 +109,11 @@ TEST_BACKUP_2 = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location=None, + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={None}, name="Test", protected=False, @@ -120,6 +138,7 @@ TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete( homeassistant_exclude_database=False, homeassistant=None, location=TEST_BACKUP_2.location, + location_attributes=TEST_BACKUP_2.location_attributes, locations=TEST_BACKUP_2.locations, name=TEST_BACKUP_2.name, protected=TEST_BACKUP_2.protected, @@ -140,6 +159,11 @@ TEST_BACKUP_3 = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location="share", + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={"share"}, name="Test", protected=False, @@ -164,6 +188,7 @@ TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( homeassistant_exclude_database=False, homeassistant=None, location=TEST_BACKUP_3.location, + location_attributes=TEST_BACKUP_3.location_attributes, locations=TEST_BACKUP_3.locations, name=TEST_BACKUP_3.name, protected=TEST_BACKUP_3.protected, @@ -185,6 +210,11 @@ TEST_BACKUP_4 = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location=None, + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={None}, name="Test", protected=False, @@ -209,6 +239,7 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( homeassistant_exclude_database=True, homeassistant="2024.12.0", location=TEST_BACKUP.location, + location_attributes=TEST_BACKUP.location_attributes, locations=TEST_BACKUP.locations, name=TEST_BACKUP.name, protected=TEST_BACKUP.protected, @@ -220,6 +251,78 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( type=TEST_BACKUP.type, ) +TEST_BACKUP_5 = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=[supervisor_backups.Folder.SHARE], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location=LOCATION_CLOUD_BACKUP, + location_attributes={ + LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, + locations={LOCATION_CLOUD_BACKUP}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP_5.compressed, + date=TEST_BACKUP_5.date, + extra=None, + folders=[supervisor_backups.Folder.SHARE], + homeassistant_exclude_database=False, + homeassistant="2024.12.0", + location=TEST_BACKUP_5.location, + location_attributes=TEST_BACKUP_5.location_attributes, + locations=TEST_BACKUP_5.locations, + name=TEST_BACKUP_5.name, + protected=TEST_BACKUP_5.protected, + repositories=[], + size=TEST_BACKUP_5.size, + size_bytes=TEST_BACKUP_5.size_bytes, + slug=TEST_BACKUP_5.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP_5.type, +) + +TEST_JOB_ID = "d17bd02be1f0437fa7264b16d38f700e" +TEST_JOB_NOT_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=False, + errors=[], + child_jobs=[], +) +TEST_JOB_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + child_jobs=[], +) + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: @@ -252,11 +355,11 @@ async def setup_integration( class BackupAgentTest(BackupAgent): """Test backup agent.""" - domain = "test" - - def __init__(self, name: str) -> None: + def __init__(self, name: str, domain: str = "test") -> None: """Initialize the backup agent.""" + self.domain = domain self.name = name + self.unique_id = name async def async_download_backup( self, backup_id: str, **kwargs: Any @@ -304,7 +407,10 @@ async def _setup_backup_platform( @pytest.mark.parametrize( ("mounts", "expected_agents"), [ - (MountsInfo(default_backup_mount=None, mounts=[]), ["hassio.local"]), + ( + MountsInfo(default_backup_mount=None, mounts=[]), + [BackupAgentTest("local", DOMAIN)], + ), ( MountsInfo( default_backup_mount=None, @@ -321,7 +427,7 @@ async def _setup_backup_platform( ) ], ), - ["hassio.local", "hassio.test"], + [BackupAgentTest("local", DOMAIN), BackupAgentTest("test", DOMAIN)], ), ( MountsInfo( @@ -339,7 +445,7 @@ async def _setup_backup_platform( ) ], ), - ["hassio.local"], + [BackupAgentTest("local", DOMAIN)], ), ], ) @@ -348,7 +454,7 @@ async def test_agent_info( hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, mounts: MountsInfo, - expected_agents: list[str], + expected_agents: list[BackupAgent], ) -> None: """Test backup agent info.""" client = await hass_ws_client(hass) @@ -361,7 +467,10 @@ async def test_agent_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": agent_id} for agent_id in expected_agents], + "agents": [ + {"agent_id": agent.agent_id, "name": agent.name} + for agent in expected_agents + ], } @@ -376,7 +485,7 @@ async def test_agent_info( "addons": [ {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} ], - "agent_ids": ["hassio.local"], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, "backup_id": "abc123", "database_included": True, "date": "1970-01-01T00:00:00+00:00", @@ -385,8 +494,6 @@ async def test_agent_info( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 1048576, "with_automatic_settings": None, }, ), @@ -397,7 +504,7 @@ async def test_agent_info( "addons": [ {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} ], - "agent_ids": ["hassio.local"], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00+00:00", @@ -406,8 +513,6 @@ async def test_agent_info( "homeassistant_included": False, "homeassistant_version": None, "name": "Test", - "protected": False, - "size": 1048576, "with_automatic_settings": None, }, ), @@ -439,7 +544,7 @@ async def test_agent_download( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP] @@ -463,7 +568,7 @@ async def test_agent_download_unavailable_backup( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup which does not exist.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP_3] @@ -525,6 +630,91 @@ async def test_agent_upload( supervisor_client.backups.remove_backup.assert_not_called() +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_agent_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {}, + "backup": { + "addons": [ + {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} + ], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, + "backup_id": "abc123", + "database_included": True, + "date": "1970-01-01T00:00:00+00:00", + "failed_agent_ids": [], + "folders": ["share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "with_automatic_settings": None, + }, + } + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("backup_info_side_effect", "expected_response"), + [ + ( + SupervisorBadRequestError("blah"), + { + "success": False, + "error": {"code": "unknown_error", "message": "Unknown error"}, + }, + ), + ( + SupervisorNotFoundError(), + { + "success": True, + "result": {"agent_errors": {}, "backup": None}, + }, + ), + ], +) +async def test_agent_get_backup_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + backup_info_side_effect: Exception, + expected_response: dict[str, Any], +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + supervisor_client.backups.backup_info.side_effect = backup_info_side_effect + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response == {"id": 1, "type": "result"} | expected_response + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_agent_delete_backup( hass: HomeAssistant, @@ -561,13 +751,6 @@ async def test_agent_delete_backup( "error": {"code": "unknown_error", "message": "Unknown error"}, }, ), - ( - SupervisorBadRequestError("Backup does not exist"), - { - "success": True, - "result": {"agent_errors": {}}, - }, - ), ( SupervisorNotFoundError(), { @@ -730,8 +913,9 @@ async def test_reader_writer_create( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -746,13 +930,14 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( expected_supervisor_options @@ -763,7 +948,7 @@ async def test_reader_writer_create( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -773,6 +958,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -780,6 +966,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "completed", } @@ -792,15 +979,306 @@ async def test_reader_writer_create( @pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_create_job_done( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup, and backup job finishes early.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + DEFAULT_BACKUP_OPTIONS + ) + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client") @pytest.mark.parametrize( - ("side_effect", "error_code", "error_message"), + ( + "commands", + "password", + "agent_ids", + "password_sent_to_supervisor", + "create_locations", + "create_protected", + "upload_locations", + ), + [ + ( + [], + None, + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2", "share3"], + False, + [], + ), + ( + [], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + [None, "share1", "share2", "share3"], + True, + [], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share1", "share2", "share3"], + True, + [None], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share2", "share3"], + True, + [None, "share1"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + "hassio.share2": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2"], + True, + ["share3"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local"], + None, + [None], + False, + [], + ), + ], +) +async def test_reader_writer_create_per_agent_encryption( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: dict[str, Any], + password: str | None, + agent_ids: list[str], + password_sent_to_supervisor: str | None, + create_locations: list[str | None], + create_protected: bool, + upload_locations: list[str | None], +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + mounts = MountsInfo( + default_backup_mount=None, + mounts=[ + supervisor_mounts.CIFSMountResponse( + share=f"share{i}", + name=f"share{i}", + read_only=False, + state=supervisor_mounts.MountState.ACTIVE, + user_path=f"share{i}", + usage=supervisor_mounts.MountUsage.BACKUP, + server=f"share{i}", + type=supervisor_mounts.MountType.CIFS, + ) + for i in range(1, 4) + ], + ) + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.backup_info.return_value = replace( + TEST_BACKUP_DETAILS, + locations=create_locations, + location_attributes={ + location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=create_protected, + size_bytes=TEST_BACKUP_DETAILS.size_bytes, + ) + for location in create_locations + }, + ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + supervisor_client.mounts.info.return_value = mounts + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] is True + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + { + "type": "backup/generate", + "agent_ids": agent_ids, + "name": "Test", + "password": password, + } + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + replace( + DEFAULT_BACKUP_OPTIONS, + password=password_sent_to_supervisor, + location=create_locations, + ) + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + assert len(supervisor_client.backups.upload_backup.mock_calls) == len( + upload_locations + ) + for call in supervisor_client.backups.upload_backup.mock_calls: + upload_call_locations: set = call.args[1].location + assert len(upload_call_locations) == 1 + assert upload_call_locations.pop() in upload_locations + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("side_effect", "error_code", "error_message", "expected_reason"), [ ( SupervisorError("Boom!"), "home_assistant_error", "Error creating backup: Boom!", + "backup_manager_error", + ), + ( + Exception("Boom!"), + "unknown_error", + "Unknown error", + "unknown_error", ), - (Exception("Boom!"), "unknown_error", "Unknown error"), ], ) async def test_reader_writer_create_partial_backup_error( @@ -810,6 +1288,7 @@ async def test_reader_writer_create_partial_backup_error( side_effect: Exception, error_code: str, error_message: str, + expected_reason: str, ) -> None: """Test client partial backup error when generating a backup.""" client = await hass_ws_client(hass) @@ -827,6 +1306,7 @@ async def test_reader_writer_create_partial_backup_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -834,6 +1314,7 @@ async def test_reader_writer_create_partial_backup_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": expected_reason, "stage": None, "state": "failed", } @@ -857,7 +1338,8 @@ async def test_reader_writer_create_missing_reference_error( ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -871,13 +1353,14 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -886,7 +1369,7 @@ async def test_reader_writer_create_missing_reference_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123"}, + "data": {"done": True, "uuid": TEST_JOB_ID}, }, } ) @@ -896,6 +1379,7 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -927,8 +1411,9 @@ async def test_reader_writer_create_download_remove_error( ) -> None: """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -954,13 +1439,14 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -969,7 +1455,7 @@ async def test_reader_writer_create_download_remove_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -979,6 +1465,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -986,6 +1473,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -1010,8 +1498,9 @@ async def test_reader_writer_create_info_error( ) -> None: """Test backup info error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.side_effect = exception + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1035,13 +1524,14 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1050,7 +1540,7 @@ async def test_reader_writer_create_info_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1060,6 +1550,7 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -1082,8 +1573,9 @@ async def test_reader_writer_create_remote_backup( ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1107,16 +1599,17 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( - replace(DEFAULT_BACKUP_OPTIONS, location=LOCATION_CLOUD_BACKUP), + replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), ) await client.send_json_auto_id( @@ -1124,7 +1617,7 @@ async def test_reader_writer_create_remote_backup( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1134,6 +1627,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -1141,6 +1635,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "completed", } @@ -1181,7 +1676,7 @@ async def test_reader_writer_create_wrong_parameters( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1197,6 +1692,7 @@ async def test_reader_writer_create_wrong_parameters( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1204,6 +1700,7 @@ async def test_reader_writer_create_wrong_parameters( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "unknown_error", "stage": None, "state": "failed", } @@ -1229,7 +1726,7 @@ async def test_agent_receive_remote_backup( """Test receiving a backup which will be uploaded to a remote agent.""" client = await hass_client() backup_id = "test-backup" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.backups.upload_backup.return_value = "test_slug" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -1283,17 +1780,33 @@ async def test_agent_receive_remote_backup( ) +@pytest.mark.parametrize( + ("get_job_result", "supervisor_events"), + [ + ( + TEST_JOB_NOT_DONE, + [{"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}], + ), + ( + TEST_JOB_DONE, + [], + ), + ], +) @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_reader_writer_restore( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + get_job_result: supervisor_jobs.Job, + supervisor_events: list[dict[str, Any]], ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = "abc123" + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = get_job_result await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1309,6 +1822,7 @@ async def test_reader_writer_restore( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1325,21 +1839,15 @@ async def test_reader_writer_restore( ), ) - await client.send_json_auto_id( - { - "type": "supervisor/event", - "data": { - "event": "job", - "data": {"done": True, "uuid": "abc123"}, - }, - } - ) - response = await client.receive_json() - assert response["success"] + for event in supervisor_events: + await client.send_json_auto_id({"type": "supervisor/event", "data": event}) + response = await client.receive_json() + assert response["success"] response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "completed", } @@ -1353,15 +1861,13 @@ async def test_reader_writer_restore( @pytest.mark.parametrize( - ("supervisor_error_string", "expected_error_code"), + ("supervisor_error_string", "expected_error_code", "expected_reason"), [ - ( - "Invalid password for backup", - "password_incorrect", - ), + ("Invalid password for backup", "password_incorrect", "password_incorrect"), ( "Backup was made on supervisor version 2025.12.0, can't restore on 2024.12.0. Must update supervisor first.", "home_assistant_error", + "unknown_error", ), ], ) @@ -1372,6 +1878,7 @@ async def test_reader_writer_restore_error( supervisor_client: AsyncMock, supervisor_error_string: str, expected_error_code: str, + expected_reason: str, ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) @@ -1393,6 +1900,7 @@ async def test_reader_writer_restore_error( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1412,6 +1920,7 @@ async def test_reader_writer_restore_error( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": expected_reason, "stage": None, "state": "failed", } @@ -1474,3 +1983,54 @@ async def test_reader_writer_restore_wrong_parameters( "code": "home_assistant_error", "message": expected_error, } + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE + + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] == { + "manager_state": "restore_backup", + "reason": "", + "stage": None, + "state": "completed", + } + assert response["result"]["state"] == "idle" + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart_unknown_job( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.side_effect = SupervisorError + + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] is None + assert response["result"]["state"] == "idle" diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index f8cac4e1a97..4c4f0e24dcc 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -17,7 +17,7 @@ from aiohasupervisor.models import ( import pytest from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 7160a2cbf16..f4b01a85900 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MOCK_REPOSITORIES, MOCK_STORE_ADDONS diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 88d7076824f..62fe49c5f23 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -16,7 +16,7 @@ from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -240,6 +240,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -254,6 +255,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non "my_nas", { "agent_ids": ["hassio.my_nas"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -281,6 +283,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 1fefe54ad75..ab8dc1475e2 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -360,6 +360,7 @@ async def test_update_addon( None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -374,6 +375,7 @@ async def test_update_addon( "my_nas", { "agent_ids": ["hassio.my_nas"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -401,6 +403,7 @@ async def test_update_addon( None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 3a774529c69..cf0d10790b7 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -1 +1,58 @@ """Tests for the Heos component.""" + +from unittest.mock import AsyncMock + +from pyheos import Heos, HeosGroup, HeosOptions, HeosPlayer + + +class MockHeos(Heos): + """Defines a mocked HEOS API.""" + + def __init__(self, options: HeosOptions) -> None: + """Initialize the mock.""" + super().__init__(options) + # Overwrite the methods with async mocks, changing type + self.add_to_queue: AsyncMock = AsyncMock() + self.connect: AsyncMock = AsyncMock() + self.disconnect: AsyncMock = AsyncMock() + self.get_favorites: AsyncMock = AsyncMock() + self.get_groups: AsyncMock = AsyncMock() + self.get_input_sources: AsyncMock = AsyncMock() + self.get_playlists: AsyncMock = AsyncMock() + self.get_players: AsyncMock = AsyncMock() + self.load_players: AsyncMock = AsyncMock() + self.play_media: AsyncMock = AsyncMock() + self.play_preset_station: AsyncMock = AsyncMock() + self.play_url: AsyncMock = AsyncMock() + self.player_clear_queue: AsyncMock = AsyncMock() + self.player_get_quick_selects: AsyncMock = AsyncMock() + self.player_play_next: AsyncMock = AsyncMock() + self.player_play_previous: AsyncMock = AsyncMock() + self.player_play_quick_select: AsyncMock = AsyncMock() + self.player_set_mute: AsyncMock = AsyncMock() + self.player_set_play_mode: AsyncMock = AsyncMock() + self.player_set_play_state: AsyncMock = AsyncMock() + self.player_set_volume: AsyncMock = AsyncMock() + self.set_group: AsyncMock = AsyncMock() + self.sign_in: AsyncMock = AsyncMock() + self.sign_out: AsyncMock = AsyncMock() + + def mock_set_players(self, players: dict[int, HeosPlayer]) -> None: + """Set the players on the mock instance.""" + for player in players.values(): + player.heos = self + self._players = players + self._players_loaded = bool(players) + self.get_players.return_value = players + + def mock_set_groups(self, groups: dict[int, HeosGroup]) -> None: + """Set the groups on the mock instance.""" + for group in groups.values(): + group.heos = self + self._groups = groups + self._groups_loaded = bool(groups) + self.get_groups.return_value = groups + + def mock_set_signed_in_username(self, signed_in_username: str | None) -> None: + """Set the signed in status on the mock instance.""" + self._signed_in_username = signed_in_username diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 122467c6b02..5ec809b10e9 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -2,15 +2,16 @@ from __future__ import annotations -from collections.abc import AsyncIterator -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Iterator +from unittest.mock import Mock, patch from pyheos import ( - CONTROLS_ALL, - Heos, HeosGroup, + HeosHost, + HeosNowPlayingMedia, HeosOptions, HeosPlayer, + HeosSystem, LineOutLevelType, MediaItem, MediaType, @@ -36,6 +37,8 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) +from . import MockHeos + from tests.common import MockConfigEntry @@ -62,6 +65,17 @@ def config_entry_options_fixture() -> MockConfigEntry: ) +@pytest.fixture(name="new_mock", autouse=True) +def new_heos_mock_fixture(controller: MockHeos) -> Iterator[Mock]: + """Patch the Heos class to return the mock instance.""" + new_mock = Mock(return_value=controller) + with ( + patch("homeassistant.components.heos.coordinator.Heos", new=new_mock), + patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), + ): + yield new_mock + + @pytest_asyncio.fixture(name="controller", autouse=True) async def controller_fixture( players: dict[int, HeosPlayer], @@ -70,36 +84,52 @@ async def controller_fixture( playlists: list[MediaItem], change_data: PlayerUpdateResult, group: dict[int, HeosGroup], -) -> AsyncIterator[Heos]: + quick_selects: dict[int, str], +) -> MockHeos: """Create a mock Heos controller fixture.""" - mock_heos = Heos(HeosOptions(host="127.0.0.1")) - for player in players.values(): - player.heos = mock_heos - mock_heos.connect = AsyncMock() - mock_heos.disconnect = AsyncMock() - mock_heos.sign_in = AsyncMock() - mock_heos.sign_out = AsyncMock() - mock_heos.get_players = AsyncMock(return_value=players) - mock_heos._players = players - mock_heos.get_favorites = AsyncMock(return_value=favorites) - mock_heos.get_input_sources = AsyncMock(return_value=input_sources) - mock_heos.get_playlists = AsyncMock(return_value=playlists) - mock_heos.load_players = AsyncMock(return_value=change_data) - mock_heos._signed_in_username = "user@user.com" - mock_heos.get_groups = AsyncMock(return_value=group) - mock_heos._groups = group - mock_heos.set_group = AsyncMock(return_value=None) - new_mock = Mock(return_value=mock_heos) - mock_heos.new_mock = new_mock - with ( - patch("homeassistant.components.heos.coordinator.Heos", new=new_mock), - patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), - ): - yield mock_heos + + mock_heos = MockHeos(HeosOptions(host="127.0.0.1")) + mock_heos.mock_set_signed_in_username("user@user.com") + mock_heos.mock_set_players(players) + mock_heos.mock_set_groups(group) + mock_heos.get_favorites.return_value = favorites + mock_heos.get_input_sources.return_value = input_sources + mock_heos.get_playlists.return_value = playlists + mock_heos.load_players.return_value = change_data + mock_heos.player_get_quick_selects.return_value = quick_selects + return mock_heos + + +@pytest.fixture(name="system") +def system_info_fixture() -> HeosSystem: + """Create a system info fixture.""" + main_host = HeosHost( + "Test Player", + "HEOS Drive HS2", + "123456", + "1.0.0", + "127.0.0.1", + NetworkType.WIRED, + ) + return HeosSystem( + "user@user.com", + main_host, + hosts=[ + main_host, + HeosHost( + "Test Player 2", + "Speaker", + "123456", + "1.0.0", + "127.0.0.2", + NetworkType.WIFI, + ), + ], + ) @pytest.fixture(name="players") -def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: +def players_fixture() -> dict[int, HeosPlayer]: """Create two mock HeosPlayers.""" players = {} for i in (1, 2): @@ -119,40 +149,19 @@ def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: shuffle=False, repeat=RepeatType.OFF, volume=25, - heos=None, ) - player.now_playing_media = Mock() - player.now_playing_media.supported_controls = CONTROLS_ALL - player.now_playing_media.album_id = 1 - player.now_playing_media.queue_id = 1 - player.now_playing_media.source_id = 1 - player.now_playing_media.station = "Station Name" - player.now_playing_media.type = "Station" - player.now_playing_media.album = "Album" - player.now_playing_media.artist = "Artist" - player.now_playing_media.media_id = "1" - player.now_playing_media.duration = None - player.now_playing_media.current_position = None - player.now_playing_media.image_url = "http://" - player.now_playing_media.song = "Song" - player.add_to_queue = AsyncMock() - player.clear_queue = AsyncMock() - player.get_quick_selects = AsyncMock(return_value=quick_selects) - player.mute = AsyncMock() - player.pause = AsyncMock() - player.play = AsyncMock() - player.play_media = AsyncMock() - player.play_next = AsyncMock() - player.play_previous = AsyncMock() - player.play_preset_station = AsyncMock() - player.play_quick_select = AsyncMock() - player.play_url = AsyncMock() - player.set_mute = AsyncMock() - player.set_play_mode = AsyncMock() - player.set_quick_select = AsyncMock() - player.set_volume = AsyncMock() - player.stop = AsyncMock() - player.unmute = AsyncMock() + player.now_playing_media = HeosNowPlayingMedia( + type=MediaType.STATION, + song="Song", + station="Station Name", + album="Album", + artist="Artist", + image_url="http://", + album_id="1", + media_id="1", + queue_id=1, + source_id=10, + ) players[player.player_id] = player return players diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..1df2d172142 --- /dev/null +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -0,0 +1,378 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '127.0.0.1', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'heos', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'HEOS System (via 127.0.0.1)', + 'unique_id': 'heos', + 'version': 1, + }), + 'favorites': dict({ + '1': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': '123456789', + 'name': "Today's Hits Radio", + 'playable': True, + 'source_id': 1, + 'type': 'station', + }), + '2': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 's1234', + 'name': 'Classical MPR (Classical Music)', + 'playable': True, + 'source_id': 3, + 'type': 'station', + }), + }), + 'groups': dict({ + '999': dict({ + 'group_id': 999, + 'is_muted': False, + 'lead_player_id': 1, + 'member_player_ids': list([ + 2, + ]), + 'name': 'Group', + 'volume': 0, + }), + }), + 'heos': dict({ + 'connection_state': 'disconnected', + 'current_credentials': None, + }), + 'inputs': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'HEOS Drive - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'Speaker - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + ]), + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'system': dict({ + 'connected_to_preferred_host': True, + 'host': dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), + 'hosts': list([ + dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), + dict({ + 'ip_address': '127.0.0.2', + 'model': 'Speaker', + 'name': 'Test Player 2', + 'network': 'wifi', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), + ]), + 'is_signed_in': True, + 'preferred_hosts': list([ + dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), + ]), + 'signed_in_username': '**REDACTED**', + }), + }) +# --- +# name: test_config_entry_diagnostics_error_getting_system + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '127.0.0.1', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'heos', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'HEOS System (via 127.0.0.1)', + 'unique_id': 'heos', + 'version': 1, + }), + 'favorites': dict({ + '1': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': '123456789', + 'name': "Today's Hits Radio", + 'playable': True, + 'source_id': 1, + 'type': 'station', + }), + '2': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 's1234', + 'name': 'Classical MPR (Classical Music)', + 'playable': True, + 'source_id': 3, + 'type': 'station', + }), + }), + 'groups': dict({ + '999': dict({ + 'group_id': 999, + 'is_muted': False, + 'lead_player_id': 1, + 'member_player_ids': list([ + 2, + ]), + 'name': 'Group', + 'volume': 0, + }), + }), + 'heos': dict({ + 'connection_state': 'disconnected', + 'current_credentials': None, + }), + 'inputs': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'HEOS Drive - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'Speaker - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + ]), + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'system': dict({ + 'error': 'Not connected to device', + }), + }) +# --- +# name: test_device_diagnostics + dict({ + 'device': dict({ + 'area_id': None, + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'identifiers': list([ + list([ + 'heos', + '1', + ]), + ]), + 'labels': list([ + ]), + 'manufacturer': 'HEOS', + 'model': 'Drive HS2', + 'model_id': None, + 'name': 'Test Player', + 'name_by_user': None, + 'serial_number': '**REDACTED**', + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + 'entities': list([ + dict({ + 'entity': dict({ + 'area_id': None, + 'categories': dict({ + }), + 'disabled_by': None, + 'entity_category': None, + 'entity_id': 'media_player.test_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_name': None, + 'platform': 'heos', + 'translation_key': None, + 'unique_id': '1', + }), + 'state': dict({ + 'attributes': dict({ + 'entity_picture': 'http://', + 'friendly_name': 'Test Player', + 'group_members': list([ + 'media_player.test_player', + 'media_player.test_player_2', + ]), + 'is_volume_muted': False, + 'media_album_id': '1', + 'media_album_name': 'Album', + 'media_artist': 'Artist', + 'media_content_id': '1', + 'media_content_type': 'music', + 'media_queue_id': 1, + 'media_source_id': 10, + 'media_station': 'Station Name', + 'media_title': 'Song', + 'media_type': 'station', + 'repeat': 'off', + 'shuffle': False, + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'supported_features': 3079741, + 'volume_level': 0.25, + }), + 'context': dict({ + 'parent_id': None, + 'user_id': None, + }), + 'entity_id': 'media_player.test_player', + 'state': 'idle', + }), + }), + ]), + 'player': dict({ + 'available': True, + 'control': 0, + 'group_id': 999, + 'ip_address': '127.0.0.1', + 'is_muted': False, + 'line_out': 1, + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'now_playing_media': dict({ + 'album': 'Album', + 'album_id': '1', + 'artist': 'Artist', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://', + 'media_id': '1', + 'options': list([ + ]), + 'queue_id': 1, + 'song': 'Song', + 'source_id': 10, + 'station': 'Station Name', + 'supported_controls': list([ + 'play', + 'pause', + 'stop', + 'play_next', + 'play_previous', + ]), + 'type': 'station', + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': 'off', + 'serial': '**REDACTED**', + 'shuffle': False, + 'state': 'stop', + 'version': '1.0.0', + 'volume': 25, + }), + }) +# --- diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 7bfdac232cb..88d27f2073a 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -9,16 +9,16 @@ 'media_player.test_player_2', ]), 'is_volume_muted': False, - 'media_album_id': 1, + 'media_album_id': '1', 'media_album_name': 'Album', 'media_artist': 'Artist', 'media_content_id': '1', 'media_content_type': , 'media_queue_id': 1, - 'media_source_id': 1, + 'media_source_id': 10, 'media_station': 'Station Name', 'media_title': 'Song', - 'media_type': 'Station', + 'media_type': , 'repeat': , 'shuffle': False, 'source_list': list([ diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 39ede354496..cbc32526958 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the Heos config flow module.""" -from pyheos import CommandAuthenticationError, CommandFailedError, Heos, HeosError +from typing import Any + +from pyheos import CommandAuthenticationError, CommandFailedError, HeosError import pytest from homeassistant.components.heos.const import DOMAIN @@ -10,6 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from . import MockHeos + from tests.common import MockConfigEntry @@ -38,7 +42,7 @@ async def test_no_host_shows_form(hass: HomeAssistant) -> None: async def test_cannot_connect_shows_error_form( - hass: HomeAssistant, controller: Heos + hass: HomeAssistant, controller: MockHeos ) -> None: """Test form is shown with error when cannot connect.""" controller.connect.side_effect = HeosError() @@ -47,13 +51,15 @@ async def test_cannot_connect_shows_error_form( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"][CONF_HOST] == "cannot_connect" + errors = result["errors"] + assert errors is not None + assert errors[CONF_HOST] == "cannot_connect" assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 async def test_create_entry_when_host_valid( - hass: HomeAssistant, controller: Heos + hass: HomeAssistant, controller: MockHeos ) -> None: """Test result type is create entry when host is valid.""" data = {CONF_HOST: "127.0.0.1"} @@ -70,7 +76,7 @@ async def test_create_entry_when_host_valid( async def test_create_entry_when_friendly_name_valid( - hass: HomeAssistant, controller: Heos + hass: HomeAssistant, controller: MockHeos ) -> None: """Test result type is create entry when friendly name is valid.""" hass.data[DOMAIN] = {"Office (127.0.0.1)": "127.0.0.1"} @@ -131,7 +137,7 @@ async def test_discovery_flow_aborts_already_setup( async def test_reconfigure_validates_and_updates_config( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test reconfigure validates host and successfully updates.""" config_entry.add_to_hass(hass) @@ -139,9 +145,9 @@ async def test_reconfigure_validates_and_updates_config( assert config_entry.data[CONF_HOST] == "127.0.0.1" # Test reconfigure initially shows form with current host value. - host = next( - key.default() for key in result["data_schema"].schema if key == CONF_HOST - ) + schema = result["data_schema"] + assert schema is not None + host = next(key.default() for key in schema.schema if key == CONF_HOST) assert host == "127.0.0.1" assert result["errors"] == {} assert result["step_id"] == "reconfigure" @@ -161,7 +167,7 @@ async def test_reconfigure_validates_and_updates_config( async def test_reconfigure_cannot_connect_recovers( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test reconfigure cannot connect and recovers.""" controller.connect.side_effect = HeosError() @@ -176,11 +182,13 @@ async def test_reconfigure_cannot_connect_recovers( assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - host = next( - key.default() for key in result["data_schema"].schema if key == CONF_HOST - ) + schema = result["data_schema"] + assert schema is not None + host = next(key.default() for key in schema.schema if key == CONF_HOST) assert host == "127.0.0.2" - assert result["errors"][CONF_HOST] == "cannot_connect" + errors = result["errors"] + assert errors is not None + assert errors[CONF_HOST] == "cannot_connect" assert result["step_id"] == "reconfigure" assert result["type"] is FlowResultType.FORM @@ -214,7 +222,7 @@ async def test_reconfigure_cannot_connect_recovers( async def test_options_flow_signs_in( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, error: HeosError, expected_error_key: str, ) -> None: @@ -255,7 +263,7 @@ async def test_options_flow_signs_in( async def test_options_flow_signs_out( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test options flow signs-out when credentials cleared.""" config_entry.add_to_hass(hass) @@ -268,7 +276,7 @@ async def test_options_flow_signs_out( assert result["type"] is FlowResultType.FORM # Fail to sign-out, show error - user_input = {} + user_input: dict[str, Any] = {} controller.sign_out.side_effect = HeosError() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input @@ -301,7 +309,7 @@ async def test_options_flow_signs_out( async def test_options_flow_missing_one_param_recovers( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, user_input: dict[str, str], expected_errors: dict[str, str], ) -> None: @@ -350,7 +358,7 @@ async def test_options_flow_missing_one_param_recovers( async def test_reauth_signs_in_aborts( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, error: HeosError, expected_error_key: str, ) -> None: @@ -391,7 +399,7 @@ async def test_reauth_signs_in_aborts( async def test_reauth_signs_out( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test reauth flow signs-out when credentials cleared and aborts.""" config_entry.add_to_hass(hass) @@ -404,7 +412,7 @@ async def test_reauth_signs_out( assert result["type"] is FlowResultType.FORM # Fail to sign-out, show error - user_input = {} + user_input: dict[str, Any] = {} controller.sign_out.side_effect = HeosError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input @@ -439,7 +447,7 @@ async def test_reauth_signs_out( async def test_reauth_flow_missing_one_param_recovers( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, user_input: dict[str, str], expected_errors: dict[str, str], ) -> None: diff --git a/tests/components/heos/test_diagnostics.py b/tests/components/heos/test_diagnostics.py new file mode 100644 index 00000000000..2a7deccfb33 --- /dev/null +++ b/tests/components/heos/test_diagnostics.py @@ -0,0 +1,100 @@ +"""Tests for the HEOS diagnostics module.""" + +from unittest import mock + +from pyheos import HeosSystem +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.heos.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import MockHeos + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + controller: MockHeos, + system: HeosSystem, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + with mock.patch.object( + controller, controller.get_system_info.__name__, return_value=system + ): + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) + + +@pytest.mark.usefixtures("controller") +async def test_config_entry_diagnostics_error_getting_system( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics with error during getting system info.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Not patching get_system_info to raise error 'Not connected to device' + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) + + +@pytest.mark.usefixtures("controller") +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, "1")}) + assert device is not None + diagnostics = await get_diagnostics_for_device( + hass, hass_client, config_entry, device + ) + assert diagnostics == snapshot( + exclude=props( + "created_at", + "modified_at", + "config_entries", + "id", + "primary_config_entry", + "config_entry_id", + "device_id", + "entity_picture_local", + "last_changed", + "last_reported", + "last_updated", + ) + ) diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 4c5eee67e2c..27dea82dcf2 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -1,8 +1,9 @@ """Tests for the init module.""" from typing import cast +from unittest.mock import Mock -from pyheos import Heos, HeosError, HeosOptions, SignalHeosEvent, SignalType +from pyheos import HeosError, HeosOptions, SignalHeosEvent, SignalType import pytest from homeassistant.components.heos.const import DOMAIN @@ -11,13 +12,15 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from . import MockHeos + from tests.common import MockConfigEntry async def test_async_setup_entry_loads_platforms( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, ) -> None: """Test load connects to heos, retrieves players, and loads platforms.""" config_entry.add_to_hass(hass) @@ -32,7 +35,10 @@ async def test_async_setup_entry_loads_platforms( async def test_async_setup_entry_with_options_loads_platforms( - hass: HomeAssistant, config_entry_options: MockConfigEntry, controller: Heos + hass: HomeAssistant, + config_entry_options: MockConfigEntry, + controller: MockHeos, + new_mock: Mock, ) -> None: """Test load connects to heos with options, retrieves players, and loads platforms.""" config_entry_options.add_to_hass(hass) @@ -40,8 +46,9 @@ async def test_async_setup_entry_with_options_loads_platforms( # Assert options passed and methods called assert config_entry_options.state is ConfigEntryState.LOADED - options = cast(HeosOptions, controller.new_mock.call_args[0][0]) + options = cast(HeosOptions, new_mock.call_args[0][0]) assert options.host == config_entry_options.data[CONF_HOST] + assert options.credentials is not None assert options.credentials.username == config_entry_options.options[CONF_USERNAME] assert options.credentials.password == config_entry_options.options[CONF_PASSWORD] assert controller.connect.call_count == 1 @@ -54,14 +61,14 @@ async def test_async_setup_entry_with_options_loads_platforms( async def test_async_setup_entry_auth_failure_starts_reauth( hass: HomeAssistant, config_entry_options: MockConfigEntry, - controller: Heos, + controller: MockHeos, ) -> None: """Test load with auth failure starts reauth, loads platforms.""" config_entry_options.add_to_hass(hass) # Simulates what happens when the controller can't sign-in during connection async def connect_send_auth_failure() -> None: - controller._signed_in_username = None + controller.mock_set_signed_in_username(None) await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) @@ -76,19 +83,19 @@ async def test_async_setup_entry_auth_failure_starts_reauth( controller.disconnect.assert_not_called() assert config_entry_options.state is ConfigEntryState.LOADED assert any( - config_entry_options.async_get_active_flows(hass, sources=[SOURCE_REAUTH]) + config_entry_options.async_get_active_flows(hass, sources={SOURCE_REAUTH}) ) async def test_async_setup_entry_not_signed_in_loads_platforms( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Test setup does not retrieve favorites when not logged in.""" config_entry.add_to_hass(hass) - controller._signed_in_username = None + controller.mock_set_signed_in_username(None) assert await hass.config_entries.async_setup(config_entry.entry_id) assert controller.connect.call_count == 1 assert controller.get_players.call_count == 1 @@ -102,7 +109,7 @@ async def test_async_setup_entry_not_signed_in_loads_platforms( async def test_async_setup_entry_connect_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Connection failure raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) @@ -114,7 +121,7 @@ async def test_async_setup_entry_connect_failure( async def test_async_setup_entry_player_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Failure to retrieve players raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) @@ -126,7 +133,7 @@ async def test_async_setup_entry_player_failure( async def test_async_setup_entry_favorites_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Failure to retrieve favorites loads.""" config_entry.add_to_hass(hass) @@ -136,7 +143,7 @@ async def test_async_setup_entry_favorites_failure( async def test_async_setup_entry_inputs_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Failure to retrieve inputs loads.""" config_entry.add_to_hass(hass) @@ -146,7 +153,7 @@ async def test_async_setup_entry_inputs_failure( async def test_unload_entry( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test entries are unloaded correctly.""" config_entry.add_to_hass(hass) @@ -164,12 +171,14 @@ async def test_device_info( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) device = device_registry.async_get_device({(DOMAIN, "1")}) + assert device is not None assert device.manufacturer == "HEOS" assert device.model == "Drive HS2" assert device.name == "Test Player" assert device.serial_number == "123456" assert device.sw_version == "1.0.0" device = device_registry.async_get_device({(DOMAIN, "2")}) + assert device is not None assert device.manufacturer == "HEOS" assert device.model == "Speaker" @@ -183,12 +192,14 @@ async def test_device_id_migration( config_entry.add_to_hass(hass) # Create a device with a legacy identifier device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, 1)} + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, 1)}, # type: ignore[arg-type] ) device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, identifiers={("Other", 1)} + config_entry_id=config_entry.entry_id, + identifiers={("Other", 1)}, # type: ignore[arg-type] ) assert await hass.config_entries.async_setup(config_entry.entry_id) - assert device_registry.async_get_device({("Other", 1)}) is not None - assert device_registry.async_get_device({(DOMAIN, 1)}) is None + assert device_registry.async_get_device({("Other", 1)}) is not None # type: ignore[arg-type] + assert device_registry.async_get_device({(DOMAIN, 1)}) is None # type: ignore[arg-type] assert device_registry.async_get_device({(DOMAIN, "1")}) is not None diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 8fc63bbc7ad..3768462eada 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -8,7 +8,6 @@ from freezegun.api import FrozenDateTimeFactory from pyheos import ( AddCriteriaType, CommandFailedError, - Heos, HeosError, MediaItem, MediaType as HeosMediaType, @@ -66,6 +65,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from . import MockHeos + from tests.common import MockConfigEntry, async_fire_time_changed @@ -88,7 +89,7 @@ async def test_state_attributes( async def test_updates_from_signals( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Tests dispatched signals update player.""" config_entry.add_to_hass(hass) @@ -97,33 +98,36 @@ async def test_updates_from_signals( # Test player does not update for other players player.state = PlayState.PLAY - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE # Test player_update standard events player.state = PlayState.PLAY - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_PLAYING # Test player_update progress events player.now_playing_media.duration = 360000 player.now_playing_media.current_position = 1000 - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_NOW_PLAYING_PROGRESS, ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] is not None assert state.attributes[ATTR_MEDIA_DURATION] == 360 assert state.attributes[ATTR_MEDIA_POSITION] == 1 @@ -132,7 +136,7 @@ async def test_updates_from_signals( async def test_updates_from_connection_event( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Tests player updates from connection event after connection failure.""" @@ -142,34 +146,37 @@ async def test_updates_from_connection_event( # Connected player.available = True - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 # Disconnected controller.load_players.reset_mock() player.available = False - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_UNAVAILABLE assert controller.load_players.call_count == 0 # Connected handles refresh failure controller.load_players.reset_mock() - controller.load_players.side_effect = CommandFailedError(None, "Failure", 1) + controller.load_players.side_effect = CommandFailedError("", "Failure", 1) player.available = True - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 assert "Unable to refresh players" in caplog.text @@ -180,7 +187,7 @@ async def test_updates_from_connection_event_new_player_ids( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, change_data_mapped_ids: PlayerUpdateResult, ) -> None: """Test player ids changed after reconnection updates ids.""" @@ -208,16 +215,15 @@ async def test_updates_from_connection_event_new_player_ids( async def test_updates_from_sources_updated( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in sources list.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] controller.get_input_sources.return_value = [] - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) freezer.tick(timedelta(seconds=1)) @@ -225,6 +231,7 @@ async def test_updates_from_sources_updated( await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ "Today's Hits Radio", "Classical MPR (Classical Music)", @@ -234,7 +241,7 @@ async def test_updates_from_sources_updated( async def test_updates_from_players_changed( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, change_data: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" @@ -242,13 +249,17 @@ async def test_updates_from_players_changed( assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - assert hass.states.get("media_player.test_player").state == STATE_IDLE + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_IDLE player.state = PlayState.PLAY - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data ) await hass.async_block_till_done() - assert hass.states.get("media_player.test_player").state == STATE_PLAYING + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_PLAYING async def test_updates_from_players_changed_new_ids( @@ -256,13 +267,12 @@ async def test_updates_from_players_changed_new_ids( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, change_data_mapped_ids: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] # Assert device registry matches current id assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) @@ -272,7 +282,7 @@ async def test_updates_from_players_changed_new_ids( == "media_player.test_player" ) - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data_mapped_ids, @@ -293,16 +303,15 @@ async def test_updates_from_players_changed_new_ids( async def test_updates_from_user_changed( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in user.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - controller._signed_in_username = None - await player.heos.dispatcher.wait_send( + controller.mock_set_signed_in_username(None) + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None ) freezer.tick(timedelta(seconds=1)) @@ -310,6 +319,7 @@ async def test_updates_from_user_changed( await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ "HEOS Drive - Line In 1", "Speaker - Line In 1", @@ -317,22 +327,28 @@ async def test_updates_from_user_changed( async def test_updates_from_groups_changed( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test player updates from changes to groups.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # Assert current state - assert hass.states.get("media_player.test_player").attributes[ - ATTR_GROUP_MEMBERS - ] == ["media_player.test_player", "media_player.test_player_2"] - assert hass.states.get("media_player.test_player_2").attributes[ - ATTR_GROUP_MEMBERS - ] == ["media_player.test_player", "media_player.test_player_2"] + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] == [ + "media_player.test_player", + "media_player.test_player_2", + ] + state = hass.states.get("media_player.test_player_2") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] == [ + "media_player.test_player", + "media_player.test_player_2", + ] # Clear group information - controller._groups = {} + controller.mock_set_groups({}) for player in controller.players.values(): player.group_id = None await controller.dispatcher.wait_send( @@ -341,40 +357,37 @@ async def test_updates_from_groups_changed( await hass.async_block_till_done() # Assert groups changed - assert ( - hass.states.get("media_player.test_player").attributes[ATTR_GROUP_MEMBERS] - is None - ) - assert ( - hass.states.get("media_player.test_player_2").attributes[ATTR_GROUP_MEMBERS] - is None - ) + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] is None + + state = hass.states.get("media_player.test_player_2") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] is None async def test_clear_playlist( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the clear playlist service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.clear_queue.call_count == 1 + assert controller.player_clear_queue.call_count == 1 async def test_clear_playlist_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test error raised when clear playlist fails.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.clear_queue.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_clear_queue.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to clear playlist: Failure (1)") ): @@ -384,33 +397,31 @@ async def test_clear_playlist_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.clear_queue.call_count == 1 + assert controller.player_clear_queue.call_count == 1 async def test_pause( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the pause service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.pause.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_pause_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the pause service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.pause.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to pause: Failure (1)") ): @@ -420,33 +431,31 @@ async def test_pause_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.pause.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_play( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_play_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to play: Failure (1)") ): @@ -456,33 +465,31 @@ async def test_play_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_previous_track( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the previous track service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_previous.call_count == 1 + assert controller.player_play_previous.call_count == 1 async def test_previous_track_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the previous track service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play_previous.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_play_previous.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to move to previous track: Failure (1)"), @@ -493,33 +500,31 @@ async def test_previous_track_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_previous.call_count == 1 + assert controller.player_play_previous.call_count == 1 async def test_next_track( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the next track service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_next.call_count == 1 + assert controller.player_play_next.call_count == 1 async def test_next_track_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the next track service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play_next.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_play_next.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to move to next track: Failure (1)"), @@ -530,33 +535,31 @@ async def test_next_track_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_next.call_count == 1 + assert controller.player_play_next.call_count == 1 async def test_stop( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the stop service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.stop.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_stop_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the stop service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.stop.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to stop: Failure (1)"), @@ -567,33 +570,31 @@ async def test_stop_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.stop.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_volume_mute( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume mute service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) - assert player.set_mute.call_count == 1 + assert controller.player_set_mute.call_count == 1 async def test_volume_mute_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume mute service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.set_mute.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_mute.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set mute: Failure (1)"), @@ -604,11 +605,11 @@ async def test_volume_mute_error( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) - assert player.set_mute.call_count == 1 + assert controller.player_set_mute.call_count == 1 async def test_shuffle_set( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the shuffle set service.""" config_entry.add_to_hass(hass) @@ -620,17 +621,17 @@ async def test_shuffle_set( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, blocking=True, ) - player.set_play_mode.assert_called_once_with(player.repeat, True) + controller.player_set_play_mode.assert_called_once_with(1, player.repeat, True) async def test_shuffle_set_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the shuffle set service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_mode.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set shuffle: Failure (1)"), @@ -641,11 +642,11 @@ async def test_shuffle_set_error( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, blocking=True, ) - player.set_play_mode.assert_called_once_with(player.repeat, True) + controller.player_set_play_mode.assert_called_once_with(1, player.repeat, True) async def test_repeat_set( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the repeat set service.""" config_entry.add_to_hass(hass) @@ -657,17 +658,19 @@ async def test_repeat_set( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_REPEAT: RepeatMode.ONE}, blocking=True, ) - player.set_play_mode.assert_called_once_with(RepeatType.ON_ONE, player.shuffle) + controller.player_set_play_mode.assert_called_once_with( + 1, RepeatType.ON_ONE, player.shuffle + ) async def test_repeat_set_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the repeat set service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_mode.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set repeat: Failure (1)"), @@ -681,33 +684,33 @@ async def test_repeat_set_error( }, blocking=True, ) - player.set_play_mode.assert_called_once_with(RepeatType.ON_ALL, player.shuffle) + controller.player_set_play_mode.assert_called_once_with( + 1, RepeatType.ON_ALL, player.shuffle + ) async def test_volume_set( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume set service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True, ) - player.set_volume.assert_called_once_with(100) + controller.player_set_volume.assert_called_once_with(1, 100) async def test_volume_set_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume set service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.set_volume.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_volume.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set volume level: Failure (1)"), @@ -718,13 +721,13 @@ async def test_volume_set_error( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True, ) - player.set_volume.assert_called_once_with(100) + controller.player_set_volume.assert_called_once_with(1, 100) async def test_select_favorite( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, favorites: dict[int, MediaItem], ) -> None: """Tests selecting a music service favorite and state.""" @@ -739,22 +742,23 @@ async def test_select_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_preset_station.assert_called_once_with(1) + controller.play_preset_station.assert_called_once_with(1, 1) # Test state is matched by station name player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = favorite.name - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE] == favorite.name async def test_select_radio_favorite( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, favorites: dict[int, MediaItem], ) -> None: """Tests selecting a radio favorite and state.""" @@ -769,32 +773,32 @@ async def test_select_radio_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_preset_station.assert_called_once_with(2) + controller.play_preset_station.assert_called_once_with(1, 2) # Test state is matched by album id player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = "Classical" player.now_playing_media.album_id = favorite.media_id - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE] == favorite.name async def test_select_radio_favorite_command_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, favorites: dict[int, MediaItem], ) -> None: """Tests command error raises when playing favorite.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] # Test set radio preset favorite = favorites[2] - player.play_preset_station.side_effect = CommandFailedError(None, "Failure", 1) + controller.play_preset_station.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to select source: Failure (1)"), @@ -808,7 +812,7 @@ async def test_select_radio_favorite_command_error( }, blocking=True, ) - player.play_preset_station.assert_called_once_with(2) + controller.play_preset_station.assert_called_once_with(1, 2) @pytest.mark.parametrize( @@ -821,7 +825,7 @@ async def test_select_radio_favorite_command_error( async def test_select_input_source( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, input_sources: list[MediaItem], source_name: str, station: str, @@ -840,21 +844,24 @@ async def test_select_input_source( }, blocking=True, ) - input_sources = next( + input_source = next( input_sources for input_sources in input_sources if input_sources.name == source_name ) - player.play_media.assert_called_once_with(input_sources) + controller.play_media.assert_called_once_with( + 1, input_source, AddCriteriaType.PLAY_NOW + ) # Update the now_playing_media to reflect play_media player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT player.now_playing_media.station = station player.now_playing_media.media_id = const.INPUT_AUX_IN_1 - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE] == source_name @@ -879,15 +886,14 @@ async def test_select_input_unknown_raises( async def test_select_input_command_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, input_sources: list[MediaItem], ) -> None: """Tests selecting an unknown input.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] input_source = input_sources[0] - player.play_media.side_effect = CommandFailedError(None, "Failure", 1) + controller.play_media.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to select source: Failure (1)"), @@ -901,7 +907,9 @@ async def test_select_input_command_error( }, blocking=True, ) - player.play_media.assert_called_once_with(input_source) + controller.play_media.assert_called_once_with( + 1, input_source, AddCriteriaType.PLAY_NOW + ) async def test_unload_config_entry( @@ -911,20 +919,21 @@ async def test_unload_config_entry( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) assert await hass.config_entries.async_unload(config_entry.entry_id) - assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) async def test_play_media( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, media_type: MediaType, ) -> None: """Test the play media service with type url.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] url = "http://news/podcast.mp3" await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -936,21 +945,20 @@ async def test_play_media( }, blocking=True, ) - player.play_url.assert_called_once_with(url) + controller.play_url.assert_called_once_with(1, url) @pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) async def test_play_media_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, media_type: MediaType, ) -> None: """Test the play media service with type url error raises.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play_url.side_effect = CommandFailedError(None, "Failure", 1) + controller.play_url.side_effect = CommandFailedError("", "Failure", 1) url = "http://news/podcast.mp3" with pytest.raises( HomeAssistantError, @@ -966,7 +974,7 @@ async def test_play_media_error( }, blocking=True, ) - player.play_url.assert_called_once_with(url) + controller.play_url.assert_called_once_with(1, url) @pytest.mark.parametrize( @@ -975,14 +983,13 @@ async def test_play_media_error( async def test_play_media_quick_select( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, content_id: str, expected_index: int, ) -> None: """Test the play media service with type quick_select.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -993,16 +1000,15 @@ async def test_play_media_quick_select( }, blocking=True, ) - player.play_quick_select.assert_called_once_with(expected_index) + controller.player_play_quick_select.assert_called_once_with(1, expected_index) async def test_play_media_quick_select_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play media service with invalid quick_select raises.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] with pytest.raises( HomeAssistantError, match=re.escape("Unable to play media: Invalid quick select 'Invalid'"), @@ -1017,7 +1023,7 @@ async def test_play_media_quick_select_error( }, blocking=True, ) - assert player.play_quick_select.call_count == 0 + assert controller.player_play_quick_select.call_count == 0 @pytest.mark.parametrize( @@ -1031,7 +1037,7 @@ async def test_play_media_quick_select_error( async def test_play_media_playlist( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, playlists: list[MediaItem], enqueue: Any, criteria: AddCriteriaType, @@ -1039,7 +1045,6 @@ async def test_play_media_playlist( """Test the play media service with type playlist.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] playlist = playlists[0] service_data = { ATTR_ENTITY_ID: "media_player.test_player", @@ -1054,16 +1059,15 @@ async def test_play_media_playlist( service_data, blocking=True, ) - player.play_media.assert_called_once_with(playlist, criteria) + controller.play_media.assert_called_once_with(1, playlist, criteria) async def test_play_media_playlist_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play media service with an invalid playlist name.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] with pytest.raises( HomeAssistantError, match=re.escape("Unable to play media: Invalid playlist 'Invalid'"), @@ -1078,7 +1082,7 @@ async def test_play_media_playlist_error( }, blocking=True, ) - assert player.add_to_queue.call_count == 0 + assert controller.add_to_queue.call_count == 0 @pytest.mark.parametrize( @@ -1087,14 +1091,13 @@ async def test_play_media_playlist_error( async def test_play_media_favorite( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, content_id: str, expected_index: int, ) -> None: """Test the play media service with type favorite.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -1105,16 +1108,15 @@ async def test_play_media_favorite( }, blocking=True, ) - player.play_preset_station.assert_called_once_with(expected_index) + controller.play_preset_station.assert_called_once_with(1, expected_index) async def test_play_media_favorite_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play media service with an invalid favorite raises.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] with pytest.raises( HomeAssistantError, match=re.escape("Unable to play media: Invalid favorite 'Invalid'"), @@ -1129,7 +1131,7 @@ async def test_play_media_favorite_error( }, blocking=True, ) - assert player.play_preset_station.call_count == 0 + assert controller.play_preset_station.call_count == 0 async def test_play_media_invalid_type( @@ -1165,7 +1167,7 @@ async def test_play_media_invalid_type( async def test_media_player_join_group( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, members: list[str], expected: tuple[int, list[int]], ) -> None: @@ -1185,7 +1187,7 @@ async def test_media_player_join_group( async def test_media_player_join_group_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test grouping of media players through the join service raises error.""" config_entry.add_to_hass(hass) @@ -1209,13 +1211,14 @@ async def test_media_player_join_group_error( async def test_media_player_group_members( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Test group_members attribute.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) player_entity = hass.states.get("media_player.test_player") + assert player_entity is not None assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [ "media_player.test_player", "media_player.test_player_2", @@ -1227,16 +1230,17 @@ async def test_media_player_group_members( async def test_media_player_group_members_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Test error in HEOS API.""" + controller.mock_set_groups({}) controller.get_groups.side_effect = HeosError("error") - controller._groups = {} config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) assert "Unable to retrieve groups" in caplog.text player_entity = hass.states.get("media_player.test_player") + assert player_entity is not None assert player_entity.attributes[ATTR_GROUP_MEMBERS] is None @@ -1247,7 +1251,7 @@ async def test_media_player_group_members_error( async def test_media_player_unjoin_group( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, entity_id: str, expected_args: list[int], ) -> None: @@ -1266,7 +1270,7 @@ async def test_media_player_unjoin_group( async def test_media_player_unjoin_group_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test ungrouping of media players through the unjoin service error raises.""" config_entry.add_to_hass(hass) @@ -1289,7 +1293,7 @@ async def test_media_player_unjoin_group_error( async def test_media_player_group_fails_when_entity_removed( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, entity_registry: er.EntityRegistry, ) -> None: """Test grouping fails when entity removed.""" @@ -1316,7 +1320,7 @@ async def test_media_player_group_fails_when_entity_removed( async def test_media_player_group_fails_wrong_integration( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, entity_registry: er.EntityRegistry, ) -> None: """Test grouping fails when trying to join from the wrong integration.""" diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index 8ca365497c6..151571ceb50 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -1,6 +1,6 @@ """Tests for the services module.""" -from pyheos import CommandAuthenticationError, Heos, HeosError +from pyheos import CommandAuthenticationError, HeosError import pytest from homeassistant.components.heos.const import ( @@ -11,13 +11,15 @@ from homeassistant.components.heos.const import ( SERVICE_SIGN_OUT, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import MockHeos from tests.common import MockConfigEntry async def test_sign_in( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the sign-in service.""" config_entry.add_to_hass(hass) @@ -34,10 +36,7 @@ async def test_sign_in( async def test_sign_in_failed( - hass: HomeAssistant, - config_entry: MockConfigEntry, - controller: Heos, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test sign-in service logs error when not connected.""" config_entry.add_to_hass(hass) @@ -47,22 +46,19 @@ async def test_sign_in_failed( "", "Invalid credentials", 6 ) - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True, + ) controller.sign_in.assert_called_once_with("test@test.com", "password") - assert "Sign in failed: Invalid credentials (6)" in caplog.text async def test_sign_in_unknown_error( - hass: HomeAssistant, - config_entry: MockConfigEntry, - controller: Heos, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test sign-in service logs error for failure.""" config_entry.add_to_hass(hass) @@ -70,15 +66,15 @@ async def test_sign_in_unknown_error( controller.sign_in.side_effect = HeosError() - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True, + ) controller.sign_in.assert_called_once_with("test@test.com", "password") - assert "Unable to sign in" in caplog.text async def test_sign_in_not_loaded_raises( @@ -99,7 +95,7 @@ async def test_sign_in_not_loaded_raises( async def test_sign_out( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the sign-out service.""" config_entry.add_to_hass(hass) @@ -123,17 +119,14 @@ async def test_sign_out_not_loaded_raises( async def test_sign_out_unknown_error( - hass: HomeAssistant, - config_entry: MockConfigEntry, - controller: Heos, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the sign-out service.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) controller.sign_out.side_effect = HeosError() - await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) + with pytest.raises(HomeAssistantError): + await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) assert controller.sign_out.call_count == 1 - assert "Unable to sign out" in caplog.text diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 3b4b02a877e..f1890073567 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -16,7 +16,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.recorder.common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index 717840c6b05..01b49ad5575 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -14,7 +14,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, STATE_OFF, STAT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed from tests.components.recorder.common import ( diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py index 301de387c80..7b84c47e81b 100644 --- a/tests/components/history/test_websocket_api_schema_32.py +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -6,7 +6,7 @@ from homeassistant.components import recorder from homeassistant.components.recorder import Recorder from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.recorder.common import ( async_recorder_block_till_done, diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 3039612d1a0..721e540b04d 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -7,7 +7,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol -from homeassistant import config as hass_config +from homeassistant import config as hass_config, core as ha from homeassistant.components.history_stats.const import ( CONF_END, CONF_START, @@ -27,12 +27,11 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_UNKNOWN, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, get_fixture_path from tests.components.recorder.common import async_wait_recording_done diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 2ac8c851e1b..af039f04c03 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -1,18 +1,32 @@ """Test fixtures for home_connect.""" -from collections.abc import Awaitable, Callable, Generator +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable +import copy import time -from typing import Any -from unittest.mock import MagicMock, Mock, PropertyMock, patch +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + ArrayOfAvailablePrograms, + ArrayOfEvents, + ArrayOfHomeAppliances, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Option, +) +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect import update_all_devices from homeassistant.components.home_connect.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -20,12 +34,17 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_object_fixture -MOCK_APPLIANCES_PROPERTIES = { - x["name"]: x - for x in load_json_object_fixture("home_connect/appliances.json")["data"][ - "homeappliances" - ] -} +MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( + load_json_object_fixture("home_connect/appliances.json")["data"] +) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture( + "home_connect/programs-available.json" +) +MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") +MOCK_STATUS = ArrayOfStatus.from_dict( + load_json_object_fixture("home_connect/status.json")["data"] +) + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -102,32 +121,23 @@ def platforms() -> list[Platform]: return [] -async def bypass_throttle(hass: HomeAssistant, config_entry: MockConfigEntry): - """Add kwarg to disable throttle.""" - await update_all_devices(hass, config_entry, no_throttle=True) - - -@pytest.fixture(name="bypass_throttle") -def mock_bypass_throttle() -> Generator[None]: - """Fixture to bypass the throttle decorator in __init__.""" - with patch( - "homeassistant.components.home_connect.update_all_devices", - side_effect=bypass_throttle, - ): - yield - - @pytest.fixture(name="integration_setup") async def mock_integration_setup( hass: HomeAssistant, platforms: list[Platform], config_entry: MockConfigEntry, -) -> Callable[[], Awaitable[bool]]: +) -> Callable[[MagicMock], Awaitable[bool]]: """Fixture to set up the integration.""" config_entry.add_to_hass(hass) - async def run() -> bool: - with patch("homeassistant.components.home_connect.PLATFORMS", platforms): + async def run(client: MagicMock) -> bool: + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch( + "homeassistant.components.home_connect.HomeConnectClient" + ) as client_mock, + ): + client_mock.return_value = client result = await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return result @@ -135,125 +145,205 @@ async def mock_integration_setup( return run -@pytest.fixture(name="get_appliances") -def mock_get_appliances() -> Generator[MagicMock]: - """Mock ConfigEntryAuth parent (HomeAssistantAPI) method.""" - with patch( - "homeassistant.components.home_connect.api.ConfigEntryAuth.get_appliances", - ) as mock: - yield mock +def _get_set_program_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey +): + """Set program side effect.""" + + async def set_program_side_effect(ha_id: str, *_, **kwargs) -> None: + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=str(kwargs["program_key"]), + ), + *[ + Event( + key=(option_event := EventKey(option.key)), + raw_key=option_event.value, + timestamp=0, + level="", + handling="", + value=str(option.key), + ) + for option in cast( + list[Option], kwargs.get("options", []) + ) + ], + ] + ), + ), + ] + ) + + return set_program_side_effect -@pytest.fixture(name="appliance") -def mock_appliance(request: pytest.FixtureRequest) -> MagicMock: +def _get_set_key_value_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], parameter_key: str +): + """Set program options side effect.""" + + async def set_key_value_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs[parameter_key]) + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + ) + ] + ), + ), + ] + ) + + return set_key_value_side_effect + + +async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePrograms: + """Get available programs.""" + appliance_type = next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type + if appliance_type not in MOCK_PROGRAMS: + raise HomeConnectApiError("error.key", "error description") + + return ArrayOfAvailablePrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) + + +async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + """Get settings.""" + return ArrayOfSettings.from_dict( + MOCK_SETTINGS.get( + next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + ) + + +@pytest.fixture(name="client") +def mock_client(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect.""" + + mock = MagicMock( + autospec=HomeConnectClient, + ) + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def add_events(events: list[EventMessage]) -> None: + await event_queue.put(events) + + mock.add_events = add_events + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.stream_all_events = stream_all_events + mock.start_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM + ) + ) + mock.set_selected_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM + ), + ) + mock.set_active_program_option = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + ) + mock.set_selected_program_option = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + ) + mock.set_setting = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"), + ) + mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) + mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) + mock.get_available_programs = AsyncMock( + side_effect=_get_available_programs_side_effect + ) + mock.put_command = AsyncMock() + + mock.side_effect = mock + return mock + + +@pytest.fixture(name="client_with_exception") +def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect that raise exceptions.""" + mock = MagicMock( + autospec=HomeConnectClient, + ) + + exception = HomeConnectError() + if hasattr(request, "param") and request.param: + exception = request.param + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.stream_all_events = stream_all_events + + mock.start_program = AsyncMock(side_effect=exception) + mock.stop_program = AsyncMock(side_effect=exception) + mock.get_available_programs = AsyncMock(side_effect=exception) + mock.set_selected_program = AsyncMock(side_effect=exception) + mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_option = AsyncMock(side_effect=exception) + mock.set_setting = AsyncMock(side_effect=exception) + mock.get_settings = AsyncMock(side_effect=exception) + mock.get_setting = AsyncMock(side_effect=exception) + mock.get_status = AsyncMock(side_effect=exception) + mock.get_available_programs = AsyncMock(side_effect=exception) + mock.put_command = AsyncMock(side_effect=exception) + + return mock + + +@pytest.fixture(name="appliance_ha_id") +def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str: """Fixture to mock Appliance.""" app = "Washer" if hasattr(request, "param") and request.param: app = request.param - - mock = MagicMock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.return_value = {} - mock.get_programs_available.return_value = [] - mock.get_status.return_value = {} - mock.get_settings.return_value = {} - - return mock - - -@pytest.fixture(name="problematic_appliance") -def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock: - """Fixture to mock a problematic Appliance.""" - app = "Washer" - if hasattr(request, "param") and request.param: - app = request.param - - mock = Mock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.side_effect = HomeConnectError - mock.get_programs_active.side_effect = HomeConnectError - mock.get_programs_available.side_effect = HomeConnectError - mock.start_program.side_effect = HomeConnectError - mock.select_program.side_effect = HomeConnectError - mock.pause_program.side_effect = HomeConnectError - mock.stop_program.side_effect = HomeConnectError - mock.set_options_active_program.side_effect = HomeConnectError - mock.set_options_selected_program.side_effect = HomeConnectError - mock.get_status.side_effect = HomeConnectError - mock.get_settings.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.execute_command.side_effect = HomeConnectError - - return mock - - -def get_all_appliances(): - """Return a list of `HomeConnectAppliance` instances for all appliances.""" - - appliances = {} - - data = load_json_object_fixture("home_connect/appliances.json").get("data") - programs_active = load_json_object_fixture("home_connect/programs-active.json") - programs_available = load_json_object_fixture( - "home_connect/programs-available.json" - ) - - def listen_callback(mock, callback): - callback["callback"](mock) - - for home_appliance in data["homeappliances"]: - api_status = load_json_object_fixture("home_connect/status.json") - api_settings = load_json_object_fixture("home_connect/settings.json") - - ha_id = home_appliance["haId"] - ha_type = home_appliance["type"] - - appliance = MagicMock(spec=HomeConnectAppliance, **home_appliance) - appliance.name = home_appliance["name"] - appliance.listen_events.side_effect = ( - lambda app=appliance, **x: listen_callback(app, x) - ) - appliance.get_programs_active.return_value = programs_active.get( - ha_type, {} - ).get("data", {}) - appliance.get_programs_available.return_value = [ - program["key"] - for program in programs_available.get(ha_type, {}) - .get("data", {}) - .get("programs", []) - ] - appliance.get_status.return_value = HomeConnectAppliance.json2dict( - api_status.get("data", {}).get("status", []) - ) - appliance.get_settings.return_value = HomeConnectAppliance.json2dict( - api_settings.get(ha_type, {}).get("data", {}).get("settings", []) - ) - setattr(appliance, "status", {}) - appliance.status.update(appliance.get_status.return_value) - appliance.status.update(appliance.get_settings.return_value) - appliance.set_setting.side_effect = ( - lambda x, y, appliance=appliance: appliance.status.update({x: {"value": y}}) - ) - appliance.start_program.side_effect = ( - lambda x, appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {"value": x}} - ) - ) - appliance.stop_program.side_effect = ( - lambda appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {}} - ) - ) - - appliances[ha_id] = appliance - - return list(appliances.values()) + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.type == app: + return appliance.ha_id + raise ValueError(f"Appliance {app} not found") diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index 1b9bec57276..a357d8fb43e 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -2,6 +2,11 @@ "Dishwasher": { "data": { "settings": [ + { + "key": "BSH.Common.Setting.ChildLock", + "value": false, + "type": "Boolean" + }, { "key": "BSH.Common.Setting.AmbientLightEnabled", "value": true, @@ -26,7 +31,13 @@ { "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", - "type": "BSH.Common.EnumType.PowerState" + "type": "BSH.Common.EnumType.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.On", + "BSH.Common.EnumType.PowerState.Off" + ] + } }, { "key": "BSH.Common.Setting.ChildLock", @@ -92,6 +103,11 @@ "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", "type": "BSH.Common.EnumType.PowerState" + }, + { + "key": "BSH.Common.Setting.AlarmClock", + "value": 0, + "type": "Integer" } ] } @@ -154,6 +170,12 @@ "max": 100, "access": "readWrite" } + }, + { + "key": "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + "value": 8, + "unit": "°C", + "type": "Double" } ] } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index f3131eac52f..f3c73a32d95 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -2,255 +2,209 @@ # name: test_async_get_config_entry_diagnostics dict({ 'BOSCH-000000000-000000000000': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/00', + 'ha_id': 'BOSCH-000000000-000000000000', + 'name': 'DNE', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'DNE', + 'vib': 'HCS000000', }), 'BOSCH-HCS000000-D00000000001': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/01', + 'ha_id': 'BOSCH-HCS000000-D00000000001', + 'name': 'WasherDryer', 'programs': list([ 'LaundryCare.WasherDryer.Program.Mix', 'LaundryCare.Washer.Option.Temperature', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'WasherDryer', + 'vib': 'HCS000001', }), 'BOSCH-HCS000000-D00000000002': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/02', + 'ha_id': 'BOSCH-HCS000000-D00000000002', + 'name': 'Refrigerator', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Refrigerator', + 'vib': 'HCS000002', }), 'BOSCH-HCS000000-D00000000003': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/03', + 'ha_id': 'BOSCH-HCS000000-D00000000003', + 'name': 'Freezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Freezer', + 'vib': 'HCS000003', }), 'BOSCH-HCS000000-D00000000004': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/04', + 'ha_id': 'BOSCH-HCS000000-D00000000004', + 'name': 'Hood', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ColorTemperature': dict({ - 'type': 'BSH.Common.EnumType.ColorTemperature', - 'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Cooking.Common.Setting.Lighting': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Cooking.Common.Setting.LightingBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Cooking.Hood.Setting.ColorTemperaturePercent': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'Cooking.Common.Setting.Lighting': True, + 'Cooking.Common.Setting.LightingBrightness': 70, + 'Cooking.Hood.Setting.ColorTemperaturePercent': 70, + 'unknown': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hood', + 'vib': 'HCS000004', }), 'BOSCH-HCS000000-D00000000005': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/05', + 'ha_id': 'BOSCH-HCS000000-D00000000005', + 'name': 'Hob', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hob', + 'vib': 'HCS000005', }), 'BOSCH-HCS000000-D00000000006': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/06', + 'ha_id': 'BOSCH-HCS000000-D00000000006', + 'name': 'CookProcessor', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CookProcessor', + 'vib': 'HCS000006', }), 'BOSCH-HCS01OVN1-43E0065FE245': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS01OVN1/03', + 'ha_id': 'BOSCH-HCS01OVN1-43E0065FE245', + 'name': 'Oven', 'programs': list([ 'Cooking.Oven.Program.HeatingMode.HotAir', 'Cooking.Oven.Program.HeatingMode.TopBottomHeating', 'Cooking.Oven.Program.HeatingMode.PizzaSetting', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'Cooking.Oven.Program.HeatingMode.HotAir', - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AlarmClock': 0, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Oven', + 'vib': 'HCS01OVN1', }), 'BOSCH-HCS04DYR1-831694AE3C5A': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS04DYR1/03', + 'ha_id': 'BOSCH-HCS04DYR1-831694AE3C5A', + 'name': 'Dryer', 'programs': list([ 'LaundryCare.Dryer.Program.Cotton', 'LaundryCare.Dryer.Program.Synthetic', 'LaundryCare.Dryer.Program.Mix', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dryer', + 'vib': 'HCS04DYR1', }), 'BOSCH-HCS06COM1-D70390681C2C': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS06COM1/03', + 'ha_id': 'BOSCH-HCS06COM1-D70390681C2C', + 'name': 'CoffeeMaker', 'programs': list([ 'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso', 'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato', @@ -259,26 +213,24 @@ 'ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato', 'ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CoffeeMaker', + 'vib': 'HCS06COM1', }), 'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -286,51 +238,30 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }), 'SIEMENS-HCS03WCH1-7BC6383CF794': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS03WCH1/03', + 'ha_id': 'SIEMENS-HCS03WCH1-7BC6383CF794', + 'name': 'Washer', 'programs': list([ 'LaundryCare.Washer.Program.Cotton', 'LaundryCare.Washer.Program.EasyCare', @@ -338,97 +269,55 @@ 'LaundryCare.Washer.Program.DelicatesSilk', 'LaundryCare.Washer.Program.Wool', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'BSH.Common.Root.ActiveProgram', - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Washer', + 'vib': 'HCS03WCH1', }), 'SIEMENS-HCS05FRF1-304F4F9E541D': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS05FRF1/03', + 'ha_id': 'SIEMENS-HCS05FRF1-304F4F9E541D', + 'name': 'FridgeFreezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Setting.Dispenser.Enabled': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.Common.Setting.Light.External.Brightness': dict({ - 'constraints': dict({ - 'access': 'readWrite', - 'max': 100, - 'min': 0, - }), - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Setting.Light.External.Power': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), + 'settings': dict({ + 'Refrigeration.Common.Setting.Dispenser.Enabled': False, + 'Refrigeration.Common.Setting.Light.External.Brightness': 70, + 'Refrigeration.Common.Setting.Light.External.Power': True, + 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator': 8, + 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': False, + 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': False, }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'FridgeFreezer', + 'vib': 'HCS05FRF1', }), }) # --- # name: test_async_get_device_diagnostics dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -436,47 +325,22 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }) # --- diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index b564b003af6..182051ad64a 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,32 +1,29 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectAPI +from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, ) from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry @pytest.fixture @@ -35,123 +32,166 @@ def platforms() -> list[str]: return [Platform.BINARY_SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test binary sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize( - ("state", "expected"), + ("value", "expected"), [ (BSH_DOOR_STATE_CLOSED, "off"), (BSH_DOOR_STATE_LOCKED, "off"), (BSH_DOOR_STATE_OPEN, "on"), - ("", "unavailable"), + ("", STATE_UNKNOWN), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors_door_states( + appliance_ha_id: str, expected: str, - state: str, + value: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Tests for Appliance door states.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": state}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ("entity_id", "event_key", "event_value_update", "expected", "appliance_ha_id"), [ + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + False, + STATE_OFF, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + True, + STATE_ON, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + "", + STATE_UNKNOWN, + "Washer", + ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_CLOSED, STATE_OFF, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_OPEN, STATE_ON, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, "", - STATE_UNAVAILABLE, + STATE_UNKNOWN, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_bianry_sensors_fridge_door_states( +async def test_binary_sensors_functionality( entity_id: str, - status_key: str, + event_key: EventKey, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Tests for Home Connect Fridge appliance door states.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" assert await async_setup_component( @@ -189,8 +229,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": BSH_DOOR_STATE_OPEN}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 80f53e20b39..c015a881343 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -3,6 +3,7 @@ from http import HTTPStatus from unittest.mock import patch +from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN import pytest from homeassistant import config_entries, setup @@ -10,11 +11,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect.const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py new file mode 100644 index 00000000000..51f42a98f42 --- /dev/null +++ b/tests/components/home_connect/test_coordinator.py @@ -0,0 +1,367 @@ +"""Test for Home Connect coordinator.""" + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) +from aiohomeconnect.model.error import ( + EventStreamInterruptedError, + HomeConnectApiError, + HomeConnectError, + HomeConnectRequestError, +) +import pytest + +from homeassistant.components.home_connect.const import ( + BSH_DOOR_STATE_LOCKED, + BSH_DOOR_STATE_OPEN, + BSH_EVENT_PRESENT_STATE_PRESENT, + BSH_POWER_OFF, +) +from homeassistant.config_entries import ConfigEntries, ConfigEntryState +from homeassistant.const import EVENT_STATE_REPORTED, Platform +from homeassistant.core import ( + Event as HassEvent, + EventStateReportedData, + HomeAssistant, + callback, +) +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR, Platform.SWITCH] + + +async def test_coordinator_update( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the coordinator can update.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_coordinator_update_failing_get_appliances( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the coordinator raises ConfigEntryNotReady when it fails to get appliances.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = HomeConnectError() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_update_failing_get_settings_status( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that although is not possible to get settings and status, the config entry is loaded. + + This is for cases where some appliances are reachable and some are not in the same configuration entry. + """ + # Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + +@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize( + ("event_type", "event_key", "event_value", "entity_id"), + [ + ( + EventType.STATUS, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "sensor.dishwasher_door", + ), + ( + EventType.NOTIFY, + EventKey.BSH_COMMON_SETTING_POWER_STATE, + BSH_POWER_OFF, + "switch.dishwasher_power", + ), + ( + EventType.EVENT, + EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + BSH_EVENT_PRESENT_STATE_PRESENT, + "sensor.dishwasher_salt_nearly_empty", + ), + ], +) +async def test_event_listener( + event_type: EventType, + event_key: EventKey, + event_value: str, + entity_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the event listener works.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + event_message = EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ) + await client.add_events([event_message]) + await hass.async_block_till_done() + + new_state = hass.states.get(entity_id) + assert new_state + assert new_state.state != state.state + + # Following, we are gonna check that the listeners are clean up correctly + new_entity_id = entity_id + "_new" + listener = MagicMock() + + @callback + def listener_callback(event: HassEvent[EventStateReportedData]) -> None: + listener(event.data["entity_id"]) + + @callback + def event_filter(_: EventStateReportedData) -> bool: + return True + + hass.bus.async_listen(EVENT_STATE_REPORTED, listener_callback, event_filter) + + entity_registry.async_update_entity(entity_id, new_entity_id=new_entity_id) + await hass.async_block_till_done() + await client.add_events([event_message]) + await hass.async_block_till_done() + + # Because the entity's id has been updated, the entity has been unloaded + # and the listener has been removed, and the new entity adds a new listener, + # so the only entity that should report states is the one with the new entity id + listener.assert_called_once_with(new_entity_id) + + +async def tests_receive_setting_and_status_for_first_time_at_events( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is capable of receiving settings and status for the first time.""" + client.get_setting = AsyncMock(return_value=ArrayOfSettings([])) + client.get_status = AsyncMock(return_value=ArrayOfStatus([])) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL, + raw_key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert len(config_entry._background_tasks) == 1 + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_event_listener_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the configuration entry is reloaded when the event stream raises an API error.""" + client_with_exception.stream_all_events = MagicMock( + side_effect=HomeConnectApiError("error.key", "error description") + ) + + with patch.object( + ConfigEntries, + "async_schedule_reload", + ) as mock_schedule_reload: + await integration_setup(client_with_exception) + await hass.async_block_till_done() + + client_with_exception.stream_all_events.assert_called_once() + mock_schedule_reload.assert_called_once_with(config_entry.entry_id) + assert not config_entry._background_tasks + + +@pytest.mark.parametrize( + "exception", + [HomeConnectRequestError(), EventStreamInterruptedError()], +) +@pytest.mark.parametrize( + ( + "entity_id", + "initial_state", + "status_key", + "status_value", + "after_refresh_expected_state", + "event_key", + "event_value", + "after_event_expected_state", + ), + [ + ( + "sensor.washer_door", + "closed", + StatusKey.BSH_COMMON_DOOR_STATE, + BSH_DOOR_STATE_LOCKED, + "locked", + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "open", + ), + ], +) +@patch( + "homeassistant.components.home_connect.coordinator.EVENT_STREAM_RECONNECT_DELAY", 0 +) +async def test_event_listener_resilience( + entity_id: str, + initial_state: str, + status_key: StatusKey, + status_value: Any, + after_refresh_expected_state: str, + event_key: EventKey, + event_value: Any, + after_event_expected_state: str, + exception: HomeConnectError, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is resilient to interruptions.""" + future = hass.loop.create_future() + + async def stream_exception(): + yield await future + + client.stream_all_events = MagicMock( + side_effect=[stream_exception(), client.stream_all_events()] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(config_entry._background_tasks) == 1 + + assert hass.states.is_state(entity_id, initial_state) + + client.get_status.return_value = ArrayOfStatus( + [Status(key=status_key, raw_key=status_key.value, value=status_value)], + ) + await hass.async_block_till_done() + future.set_exception(exception) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert client.stream_all_events.call_count == 2 + assert hass.states.is_state(entity_id, after_refresh_expected_state) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, after_event_expected_state) diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index f2db6e2b67a..ab6823411dc 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -1,11 +1,9 @@ """Test diagnostics for Home Connect.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError -import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.home_connect.diagnostics import ( @@ -16,43 +14,37 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .conftest import get_all_appliances - from tests.common import MockConfigEntry -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_device_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED device = device_registry.async_get_or_create( @@ -61,69 +53,3 @@ async def test_async_get_device_diagnostics( ) assert await async_get_device_diagnostics(hass, config_entry, device) == snapshot - - -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_not_found( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, "Random-Device-ID")}, - ) - - with pytest.raises(ValueError): - await async_get_device_diagnostics(hass, config_entry, device) - - -@pytest.mark.parametrize( - ("api_error", "expected_connection_status"), - [ - (HomeConnectError(), "unknown"), - ( - HomeConnectError( - { - "key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed", - } - ), - "offline", - ), - ], -) -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_api_error( - api_error: HomeConnectError, - expected_connection_status: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - appliance.get_programs_available.side_effect = api_error - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.haId)}, - ) - - diagnostics = await async_get_device_diagnostics(hass, config_entry, device) - assert diagnostics["programs"] is None diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 69601efb42d..f62feca700a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -2,27 +2,18 @@ from collections.abc import Awaitable, Callable from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch -from freezegun.api import FrozenDateTimeFactory +from aiohomeconnect.const import OAUTH2_TOKEN +from aiohomeconnect.model import SettingKey, StatusKey +from aiohomeconnect.model.error import HomeConnectError import pytest -from requests import HTTPError import requests_mock +import respx from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.home_connect import ( - SCAN_INTERVAL, - bsh_key_to_translation_key, -) -from homeassistant.components.home_connect.const import ( - BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, - BSH_POWER_STATE, - BSH_REMOTE_START_ALLOWANCE_STATE, - COOKING_LIGHTING, - DOMAIN, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.components.home_connect.utils import bsh_key_to_translation_key from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -39,7 +30,6 @@ from .conftest import ( FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, SERVER_ACCESS_TOKEN, - get_all_appliances, ) from tests.common import MockConfigEntry @@ -126,28 +116,26 @@ SERVICE_PROGRAM_CALL_PARAMS = [ ] SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_options_active_program", - "set_option_selected": "set_options_selected_program", + "set_option_active": "set_active_program_option", + "set_option_selected": "set_selected_program_option", "change_setting": "set_setting", - "pause_program": "execute_command", - "resume_program": "execute_command", - "select_program": "select_program", + "pause_program": "put_command", + "resume_program": "put_command", + "select_program": "set_selected_program", "start_program": "start_program", } -@pytest.mark.usefixtures("bypass_throttle") -async def test_api_setup( +async def test_entry_setup( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test setup and unload.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await hass.config_entries.async_unload(config_entry.entry_id) @@ -156,72 +144,60 @@ async def test_api_setup( assert config_entry.state == ConfigEntryState.NOT_LOADED -async def test_update_throttle( - appliance: Mock, - freezer: FrozenDateTimeFactory, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, -) -> None: - """Test to check Throttle functionality.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - get_appliances_call_count = get_appliances.call_count - - # First re-load after 1 minute is not blocked. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds + 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - # Second re-load is blocked by Throttle. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds - 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - -@pytest.mark.usefixtures("bypass_throttle") async def test_exception_handling( - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize("token_expiration_time", [12345]) -@pytest.mark.usefixtures("bypass_throttle") +@respx.mock async def test_token_refresh_success( - integration_setup: Callable[[], Awaitable[bool]], + hass: HomeAssistant, + platforms: list[Platform], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, requests_mock: requests_mock.Mocker, setup_credentials: None, + client: MagicMock, ) -> None: """Test where token is expired and the refresh attempt succeeds.""" assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN) - requests_mock.get("/api/homeappliances", json={"data": {"homeappliances": []}}) - aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, ) - assert await integration_setup() + appliances = client.get_home_appliances.return_value + + async def mock_get_home_appliances(): + await client._auth.async_get_access_token() + return appliances + + client.get_home_appliances.return_value = None + client.get_home_appliances.side_effect = mock_get_home_appliances + + def init_side_effect(auth) -> MagicMock: + client._auth = auth + return client + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock, + ): + client_mock.side_effect = MagicMock(side_effect=init_side_effect) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.LOADED # Verify token request @@ -240,45 +216,43 @@ async def test_token_refresh_success( ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_http_error( +async def test_client_error( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: - """Test HTTP errors during setup integration.""" - get_appliances.side_effect = HTTPError(response=MagicMock()) + """Test client errors during setup integration.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = HomeConnectError() assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - assert get_appliances.call_count == 1 + assert not await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert client_with_exception.get_home_appliances.call_count == 1 @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") async def test_services( - service_call: list[dict[str, Any]], + service_call: dict[str, Any], hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, ) -> None: """Create and test services.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_name = service_call["service"] @@ -286,8 +260,7 @@ async def test_services( await hass.services.async_call(**service_call) await hass.async_block_till_done() assert ( - getattr(appliance, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count - == 1 + getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 ) @@ -295,26 +268,24 @@ async def test_services( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") async def test_services_exception( - service_call: list[dict[str, Any]], + service_call: dict[str, Any], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, + appliance_ha_id: str, device_registry: dr.DeviceRegistry, ) -> None: """Raise a HomeAssistantError when there is an API error.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, problematic_appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -323,25 +294,47 @@ async def test_services_exception( await hass.services.async_call(**service_call) -@pytest.mark.usefixtures("bypass_throttle") async def test_services_appliance_not_found( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + device_registry: dr.DeviceRegistry, ) -> None: """Raise a ServiceValidationError when device id does not match.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED service_call = SERVICE_KV_CALL_PARAMS[0] service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" + with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): + await hass.services.async_call(**service_call) + + unrelated_config_entry = MockConfigEntry( + domain="TEST", + ) + unrelated_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=unrelated_config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises( + ServiceValidationError, match=r"Home Connect config entry.*not found" + ): + await hass.services.async_call(**service_call) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): await hass.services.async_call(**service_call) @@ -351,7 +344,7 @@ async def test_entity_migration( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_v1_1: MockConfigEntry, - appliance: Mock, + appliance_ha_id: str, platforms: list[Platform], ) -> None: """Test entity migration.""" @@ -360,34 +353,39 @@ async def test_entity_migration( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_v1_1.entry_id, - identifiers={(DOMAIN, appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) test_entities = [ ( SENSOR_DOMAIN, "Operation State", - BSH_OPERATION_STATE, + StatusKey.BSH_COMMON_OPERATION_STATE, ), ( SWITCH_DOMAIN, "ChildLock", - BSH_CHILD_LOCK_STATE, + SettingKey.BSH_COMMON_CHILD_LOCK, ), ( SWITCH_DOMAIN, "Power", - BSH_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE, ), ( BINARY_SENSOR_DOMAIN, "Remote Start", - BSH_REMOTE_START_ALLOWANCE_STATE, + StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, ), ( LIGHT_DOMAIN, "Light", - COOKING_LIGHTING, + SettingKey.COOKING_COMMON_LIGHTING, + ), + ( # An already migrated entity + SWITCH_DOMAIN, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, ), ] @@ -395,7 +393,7 @@ async def test_entity_migration( entity_registry.async_get_or_create( domain, DOMAIN, - f"{appliance.haId}-{old_unique_id_suffix}", + f"{appliance_ha_id}-{old_unique_id_suffix}", device_id=device_entry.id, config_entry=config_entry_v1_1, ) @@ -406,7 +404,7 @@ async def test_entity_migration( for domain, _, expected_unique_id_suffix in test_entities: assert entity_registry.async_get_entity_id( - domain, DOMAIN, f"{appliance.haId}-{expected_unique_id_suffix}" + domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}" ) assert config_entry_v1_1.minor_version == 2 diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 471ddf0ec54..4f8cb60d881 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -1,20 +1,24 @@ """Tests for home_connect light entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import MagicMock, call -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError import pytest from homeassistant.components.home_connect.const import ( - BSH_AMBIENT_LIGHT_BRIGHTNESS, - BSH_AMBIENT_LIGHT_COLOR, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_ENABLED, - COOKING_LIGHTING, - COOKING_LIGHTING_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_POWER, + BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -23,26 +27,15 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, - STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Hood" -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get(TEST_HC_APP) - .get("data") - .get("settings") -} - @pytest.fixture def platforms() -> list[str]: @@ -51,29 +44,31 @@ def platforms() -> list[str]: async def test_light( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize( - ("entity_id", "status", "service", "service_data", "state", "appliance"), + ( + "entity_id", + "set_settings_args", + "service", + "exprected_attributes", + "state", + "appliance_ha_id", + ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, @@ -83,58 +78,18 @@ async def test_light( ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 200}, + {"brightness": 199}, STATE_ON, "Hood", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_OFF, - {}, - STATE_OFF, - "Hood", - ), - ( - "light.hood_functional_light", - { - COOKING_LIGHTING: { - "value": None, - }, - COOKING_LIGHTING_BRIGHTNESS: None, - }, - SERVICE_TURN_ON, - {}, - STATE_UNKNOWN, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_ON, - {"brightness": 200}, - STATE_ON, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: {"value": False}, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, @@ -144,8 +99,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80, + }, + SERVICE_TURN_ON, + {"brightness": 199}, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: False, + }, + SERVICE_TURN_OFF, + {}, + STATE_OFF, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, }, SERVICE_TURN_ON, {}, @@ -155,15 +130,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_COLOR: { - "value": "", - }, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, { - "rgb_color": [255, 255, 0], + "rgb_color": (255, 255, 0), + }, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, }, STATE_ON, "Hood", @@ -171,10 +159,7 @@ async def test_light( ( "light.fridgefreezer_external_light", { - REFRIGERATION_EXTERNAL_LIGHT_POWER: { - "value": True, - }, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS: {"value": 75}, + SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER: True, }, SERVICE_TURN_ON, {}, @@ -182,167 +167,268 @@ async def test_light( "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_light_functionality( entity_id: str, - status: dict, + set_settings_args: dict[SettingKey, Any], service: str, - service_data: dict, + exprected_attributes: dict[str, Any], state: str, - appliance: Mock, - bypass_throttle: Generator[None], + appliance_ha_id: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test light functionality.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) + service_data = exprected_attributes.copy() service_data["entity_id"] = entity_id await hass.services.async_call( LIGHT_DOMAIN, service, - service_data, - blocking=True, + {key: value for key, value in service_data.items() if value is not None}, ) - assert hass.states.is_state(entity_id, state) + await hass.async_block_till_done() + client.set_setting.assert_has_calls( + [ + call(appliance_ha_id, setting_key=setting_key, value=value) + for setting_key, value in set_settings_args.items() + ] + ) + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == state + for key, value in exprected_attributes.items(): + assert entity_state.attributes[key] == value @pytest.mark.parametrize( ( "entity_id", - "status", + "events", + "appliance_ha_id", + ), + [ + ( + "light.hood_ambient_light", + { + EventKey.BSH_COMMON_SETTING_AMBIENT_LIGHT_COLOR: "BSH.Common.EnumType.AmbientLightColor.Color1", + }, + "Hood", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_light_color_different_than_custom( + entity_id: str, + events: dict[EventKey, Any], + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that light color attributes are not set if color is different than custom.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + "rgb_color": (255, 255, 0), + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is not None + assert entity_state.attributes["hs_color"] is not None + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + for event_key, value in events.items() + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is None + assert entity_state.attributes["hs_color"] is None + + +@pytest.mark.parametrize( + ( + "entity_id", + "setting", "service", "service_data", - "mock_attr", "attr_side_effect", - "problematic_appliance", "exception_match", ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": False, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*off.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, None, HomeConnectError], - "Hood", + r"Error.*set.*brightness.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, HomeConnectError], + r"Error.*select.*custom color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, None, HomeConnectError], + r"Error.*set.*color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, + }, + [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", ), ], - indirect=["problematic_appliance"], ) -async def test_switch_exception_handling( +async def test_light_exception_handling( entity_id: str, - status: dict, + setting: dict[SettingKey, dict[str, Any]], service: str, service_data: dict, - mock_attr: str, - attr_side_effect: list, - problematic_appliance: Mock, + attr_side_effect: list[type[HomeConnectError] | None], exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test light exception handling.""" - problematic_appliance.status.update(SETTINGS_STATUS) - problematic_appliance.set_setting.side_effect = attr_side_effect - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=value, + ) + for setting_key, value in setting.items() + ] + ) + client_with_exception.set_setting.side_effect = [ + exception() if exception else None for exception in attr_side_effect + ] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await client_with_exception.set_setting() - problematic_appliance.status.update(status) service_data["entity_id"] = entity_id with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( LIGHT_DOMAIN, service, service_data, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == len(attr_side_effect) + assert client_with_exception.set_setting.call_count == len(attr_side_effect) diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index bce19161cf8..371aed928dd 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -1,22 +1,17 @@ """Tests for home_connect number entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable import random -from unittest.mock import MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.setting import SettingConstraints import pytest -from homeassistant.components.home_connect.const import ( - ATTR_CONSTRAINTS, - ATTR_STEPSIZE, - ATTR_UNIT, - ATTR_VALUE, -) from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, ATTR_VALUE as SERVICE_ATTR_VALUE, + DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -26,8 +21,6 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - from tests.common import MockConfigEntry @@ -38,25 +31,24 @@ def platforms() -> list[str]: async def test_number( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test number entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Refrigerator"], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize( ( "entity_id", "setting_key", + "type", + "expected_state", "min_value", "max_value", "step_size", @@ -64,102 +56,132 @@ async def test_number( ), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, 7, 15, 0.1, "°C", ), + ( + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, + 7, + 15, + 5, + "°C", + ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, + type: str, + expected_state: int, min_value: int, max_value: int, step_size: float, unit_of_measurement: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test number entity functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_MIN: min_value, - ATTR_MAX: max_value, - ATTR_STEPSIZE: step_size, - }, - ATTR_UNIT: unit_of_measurement, - } - ] - get_appliances.return_value = [appliance] - current_value = min_value - appliance.status.update({setting_key: {ATTR_VALUE: current_value}}) + client.get_setting.side_effect = None + client.get_setting = AsyncMock( + return_value=GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # This should not change the value + unit=unit_of_measurement, + type=type, + constraints=SettingConstraints( + min=min_value, + max=max_value, + step_size=step_size if isinstance(step_size, int) else None, + ), + ) + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, str(current_value)) - state = hass.states.get(entity_id) - assert state.attributes["min"] == min_value - assert state.attributes["max"] == max_value - assert state.attributes["step"] == step_size - assert state.attributes["unit_of_measurement"] == unit_of_measurement + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.state == str(expected_state) + attributes = entity_state.attributes + assert attributes["min"] == min_value + assert attributes["max"] == max_value + assert attributes["step"] == step_size + assert attributes["unit_of_measurement"] == unit_of_measurement - new_value = random.randint(min_value + 1, max_value) + value = random.choice( + [num for num in range(min_value, max_value + 1) if num != expected_state] + ) await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - SERVICE_ATTR_VALUE: new_value, + SERVICE_ATTR_VALUE: value, }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(float(value))) -@pytest.mark.parametrize("problematic_appliance", ["Refrigerator"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test number entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=DEFAULT_MIN_VALUE, + constraints=SettingConstraints( + min=int(DEFAULT_MIN_VALUE), + max=int(DEFAULT_MAX_VALUE), + step_size=1, + ), + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -173,4 +195,4 @@ async def test_number_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index af975979196..6ebd37266cd 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -1,39 +1,38 @@ """Tests for home_connect select entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ( + ArrayOfAvailablePrograms, + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + ProgramKey, +) +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.program import EnumerateAvailableProgram import pytest -from homeassistant.components.home_connect.const import ( - BSH_ACTIVE_PROGRAM, - BSH_SELECTED_PROGRAM, -) from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_SELECT_OPTION, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Washer") - .get("data") - .get("settings") -} - -PROGRAM = "Dishcare.Dishwasher.Program.Eco50" +from tests.common import MockConfigEntry @pytest.fixture @@ -43,119 +42,148 @@ def platforms() -> list[str]: async def test_select( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test select entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED async def test_filter_unknown_programs( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, - appliance: Mock, + client: MagicMock, entity_registry: er.EntityRegistry, ) -> None: - """Test select that programs that are not part of the official Home Connect API specification are filtered out. - - We use two programs to ensure that programs are iterated over a copy of the list, - and it does not raise problems when removing an element from the original list. - """ - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [ - PROGRAM, - "NonOfficialProgram", - "AntotherNonOfficialProgram", - ] - get_appliances.return_value = [appliance] + """Test select that only known programs are shown.""" + client.get_available_programs.side_effect = None + client.get_available_programs.return_value = ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ), + EnumerateAvailableProgram( + key=ProgramKey.UNKNOWN, + raw_key="an unknown program", + ), + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - entity = entity_registry.async_get("select.washer_selected_program") + entity = entity_registry.async_get("select.dishwasher_selected_program") assert entity - assert entity.capabilities.get(ATTR_OPTIONS) == [ - "dishcare_dishwasher_program_eco_50" - ] + assert entity.capabilities + assert entity.capabilities[ATTR_OPTIONS] == ["dishcare_dishwasher_program_eco_50"] @pytest.mark.parametrize( - ("entity_id", "status", "program_to_set"), + ( + "appliance_ha_id", + "entity_id", + "mock_method", + "program_key", + "program_to_set", + "event_key", + ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_selected_program", + "set_selected_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_active_program", + "start_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, ), ], + indirect=["appliance_ha_id"], ) -async def test_select_functionality( +async def test_select_program_functionality( + appliance_ha_id: str, entity_id: str, - status: dict, + mock_method: str, + program_key: ProgramKey, program_to_set: str, - bypass_throttle: Generator[None], + event_key: EventKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test select functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - appliance.status.update(status) + assert hass.states.is_state(entity_id, "unknown") await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, - blocking=True, + ) + await hass.async_block_till_done() + getattr(client, mock_method).assert_awaited_once_with( + appliance_ha_id, program_key=program_key ) assert hass.states.is_state(entity_id, program_to_set) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value="A not known program", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + @pytest.mark.parametrize( ( "entity_id", - "status", "program_to_set", "mock_attr", "exception_match", ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_selected_program", "dishcare_dishwasher_program_eco_50", - "select_program", + "set_selected_program", r"Error.*select.*program.*", ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_active_program", "dishcare_dishwasher_program_eco_50", "start_program", r"Error.*start.*program.*", @@ -164,32 +192,36 @@ async def test_select_functionality( ) async def test_select_exception_handling( entity_id: str, - status: dict, program_to_set: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_available_programs.side_effect = None + client_with_exception.get_available_programs.return_value = ( + ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() - problematic_appliance.status.update(status) with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SELECT_DOMAIN, @@ -197,4 +229,4 @@ async def test_select_exception_handling( {"entity_id": entity_id, "option": program_to_set}, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index f2ee3b13922..ce06a841bbb 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,75 +1,77 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock +from aiohomeconnect.model import ( + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) from freezegun.api import FrozenDateTimeFactory -from homeconnect.api import HomeConnectAPI import pytest from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_CONFIRMED, BSH_EVENT_PRESENT_STATE_OFF, BSH_EVENT_PRESENT_STATE_PRESENT, - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Dishwasher" EVENT_PROG_DELAYED_START = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" - }, -} - -EVENT_PROG_REMAIN_NO_VALUE = { - "BSH.Common.Option.RemainingProgramTime": {}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.DelayedStart", }, } EVENT_PROG_RUN = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "60"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", + }, + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 60, }, } - EVENT_PROG_UPDATE_1 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "80"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 80, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_UPDATE_2 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "20"}, - "BSH.Common.Option.ProgramProgress": {"value": "99"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 20, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 99, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_END = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Ready" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Ready", }, } @@ -80,22 +82,19 @@ def platforms() -> list[str]: return [Platform.SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -# Appliance program sequence with a delayed start. +# Appliance_ha_id program sequence with a delayed start. PROGRAM_SEQUENCE_EVENTS = ( EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, @@ -130,7 +129,7 @@ ENTITY_ID_STATES = { } -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("states", "event_run"), list( @@ -141,17 +140,16 @@ ENTITY_ID_STATES = { ) ), ) -@pytest.mark.usefixtures("bypass_throttle") async def test_event_sensors( - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, states: tuple, - event_run: dict, + event_run: dict[EventType, dict[EventKey, str | int]], freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, ) -> None: """Test sequence for sensors that are only available after an event happens.""" entity_ids = ENTITY_ID_STATES.keys() @@ -159,24 +157,48 @@ async def test_event_sensors( time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_DELAYED_START) - assert await integration_setup() + client.get_status.return_value.status.extend( + Status( + key=StatusKey(event_key.value), + raw_key=event_key.value, + value=value, + ) + for event_key, value in EVENT_PROG_DELAYED_START[EventType.STATUS].items() + ) + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(event_run) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event_run.items() + for event_key, value in events.items() + ] + ) + await hass.async_block_till_done() for entity_id, state in zip(entity_ids, states, strict=False): - await async_update_entity(hass, entity_id) - await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) # Program sequence for SensorDeviceClass.TIMESTAMP edge cases. PROGRAM_SEQUENCE_EDGE_CASE = [ - EVENT_PROG_REMAIN_NO_VALUE, + EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, EVENT_PROG_END, EVENT_PROG_END, @@ -191,60 +213,86 @@ ENTITY_ID_EDGE_CASE_STATES = [ ] -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) -@pytest.mark.usefixtures("bypass_throttle") +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) async def test_remaining_prog_time_edge_cases( - appliance: Mock, + appliance_ha_id: str, freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Run program sequence to test edge cases for the remaining_prog_time entity.""" - get_appliances.return_value = [appliance] entity_id = "sensor.dishwasher_program_finish_time" time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_REMAIN_NO_VALUE) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED for ( event, expected_state, ) in zip(PROGRAM_SEQUENCE_EDGE_CASE, ENTITY_ID_EDGE_CASE_STATES, strict=False): - appliance.status.update(event) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event.items() + for event_key, value in events.items() + ] + ) await hass.async_block_till_done() freezer.tick() assert hass.states.is_state(entity_id, expected_state) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ( + "entity_id", + "event_key", + "event_type", + "event_value_update", + "expected", + "appliance_ha_id", + ), [ ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_LOCKED, "locked", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_CLOSED, "closed", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_OPEN, "open", "Dishwasher", @@ -252,33 +300,38 @@ async def test_remaining_prog_time_edge_cases( ( "sensor.fridgefreezer_freezer_door_alarm", "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", + EventType.EVENT, "", "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "FridgeFreezer", ), ( "sensor.coffeemaker_bean_container_empty", + EventType.EVENT, "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", "", "off", @@ -286,52 +339,68 @@ async def test_remaining_prog_time_edge_cases( ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "CoffeeMaker", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors_states( entity_id: str, - status_key: str, + event_key: EventKey, + event_type: EventType, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: - """Tests for Appliance alarm sensors.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] + """Tests for Appliance_ha_id alarm sensors.""" assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ), + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 9d54feeaa54..10d393423be 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -1,24 +1,34 @@ """Tests for home_connect sensor entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfSettings, + Event, + EventKey, + EventMessage, + GetSetting, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.event import ArrayOfEvents, EventType +from aiohomeconnect.model.program import ( + ArrayOfAvailablePrograms, + EnumerateAvailableProgram, +) +from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - ATTR_ALLOWED_VALUES, - ATTR_CONSTRAINTS, - BSH_ACTIVE_PROGRAM, - BSH_CHILD_LOCK_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, - BSH_POWER_STATE, DOMAIN, - REFRIGERATION_SUPERMODEFREEZER, ) from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -33,22 +43,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Dishwasher") - .get("data") - .get("settings") -} - -PROGRAM = "LaundryCare.Dryer.Program.Mix" +from tests.common import MockConfigEntry @pytest.fixture @@ -58,231 +56,285 @@ def platforms() -> list[str]: async def test_switches( - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -@pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), - [ - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": ""}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": True}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": False}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ], - indirect=["appliance"], -) -async def test_switch_functionality( - entity_id: str, - status: dict, - service: str, - state: str, - bypass_throttle: Generator[None], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, -) -> None: - """Test switch functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True - ) - assert hass.states.is_state(entity_id, state) - - @pytest.mark.parametrize( ( "entity_id", - "status", + "service", + "settings_key_arg", + "setting_value_arg", + "state", + "appliance_ha_id", + ), + [ + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_ON, + SettingKey.BSH_COMMON_CHILD_LOCK, + True, + STATE_ON, + "Dishwasher", + ), + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_OFF, + SettingKey.BSH_COMMON_CHILD_LOCK, + False, + STATE_OFF, + "Dishwasher", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_switch_functionality( + entity_id: str, + settings_key_arg: SettingKey, + setting_value_arg: Any, + service: str, + state: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=settings_key_arg, value=setting_value_arg + ) + assert hass.states.is_state(entity_id, state) + + +@pytest.mark.parametrize( + ("entity_id", "program_key", "appliance_ha_id"), + [ + ( + "switch.dryer_program_mix", + ProgramKey.LAUNDRY_CARE_DRYER_MIX, + "Dryer", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_program_switch_functionality( + entity_id: str, + program_key: ProgramKey, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + async def mock_stop_program(ha_id: str) -> None: + """Mock stop program.""" + await client.add_events( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ), + ] + ) + + client.stop_program = AsyncMock(side_effect=mock_stop_program) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_ON) + client.start_program.assert_awaited_once_with( + appliance_ha_id, program_key=program_key + ) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_OFF) + client.stop_program.assert_awaited_once_with(appliance_ha_id) + + +@pytest.mark.parametrize( + ( + "entity_id", "service", "mock_attr", - "problematic_appliance", "exception_match", ), [ ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_ON, "start_program", - "Dishwasher", r"Error.*start.*program.*", ), ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_OFF, "stop_program", - "Dishwasher", r"Error.*stop.*program.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], ) async def test_switch_exception_handling( entity_id: str, - status: dict, service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_available_programs.side_effect = None + client_with_exception.get_available_programs.return_value = ( + ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) + ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_CHILD_LOCK, + raw_key=SettingKey.BSH_COMMON_CHILD_LOCK.value, + value=False, + ), + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=[BSH_POWER_ON, BSH_POWER_OFF] + ), + ), + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - problematic_appliance.status.update(status) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), + ("entity_id", "status", "service", "state", "appliance_ha_id"), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": True}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: True}, SERVICE_TURN_ON, STATE_ON, "FridgeFreezer", ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": False}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: False}, SERVICE_TURN_OFF, STATE_OFF, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_functionality( entity_id: str, status: dict, service: str, state: str, - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test switch functionality - entity description setup.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) @@ -292,13 +344,13 @@ async def test_ent_desc_switch_functionality( "status", "service", "mock_attr", - "problematic_appliance", + "appliance_ha_id", "exception_match", ), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_ON, "set_setting", "FridgeFreezer", @@ -306,203 +358,257 @@ async def test_ent_desc_switch_functionality( ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_OFF, "set_setting", "FridgeFreezer", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_exception_handling( entity_id: str, - status: dict, + status: dict[SettingKey, str], service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client_with_exception: MagicMock, ) -> None: """Test switch exception handling - entity description setup.""" - problematic_appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(problematic_appliance.name) - .get("data") - .get("settings") - ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=key, + raw_key=key.value, + value=value, + ) + for key, value in status.items() + ] ) - get_appliances.return_value = [problematic_appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() - - problematic_appliance.status.update(status) + await client_with_exception.set_setting() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert client_with_exception.set_setting.call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "allowed_values", "service", "power_state", "appliance"), + ( + "entity_id", + "allowed_values", + "service", + "setting_value_arg", + "power_state", + "appliance_ha_id", + ), [ ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_OFF, + BSH_POWER_OFF, STATE_OFF, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_STANDBY}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_OFF, + BSH_POWER_STANDBY, STATE_OFF, "Dishwasher", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_swtich( entity_id: str, - status: dict, - allowed_values: list[str], + allowed_values: list[str | None] | None, service: str, + setting_value_arg: str, power_state: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test power switch functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - appliance.status.update(status) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value="", + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=setting_value_arg, ) assert hass.states.is_state(entity_id, power_state) @pytest.mark.parametrize( - ("entity_id", "allowed_values", "service", "appliance", "exception_match"), + ("initial_value"), + [ + (BSH_POWER_OFF), + (BSH_POWER_STANDBY), + ], +) +async def test_power_switch_fetch_off_state_from_current_value( + initial_value: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test power switch functionality to fetch the off state from the current value.""" + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=initial_value, + ) + ] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + + +@pytest.mark.parametrize( + ("entity_id", "allowed_values", "service", "exception_match"), [ ( "switch.dishwasher_power", [BSH_POWER_ON], SERVICE_TURN_OFF, - "Dishwasher", r".*not support.*turn.*off.*", ), ( "switch.dishwasher_power", None, SERVICE_TURN_OFF, - "Dishwasher", + r".*Unable.*turn.*off.*support.*not.*determined.*", + ), + ( + "switch.dishwasher_power", + HomeConnectError(), + SERVICE_TURN_OFF, r".*Unable.*turn.*off.*support.*not.*determined.*", ), ], - indirect=["appliance"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_switch_service_validation_errors( entity_id: str, - allowed_values: list[str], + allowed_values: list[str | None] | None | HomeConnectError, service: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, exception_match: str, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test power switch functionality validation errors.""" - if allowed_values: - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + if isinstance(allowed_values, HomeConnectError): + exception = allowed_values + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + ) + ] + ) + client.get_setting = AsyncMock(side_effect=exception) + else: + setting = GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + client.get_settings.return_value = ArrayOfSettings([setting]) + client.get_setting = AsyncMock(return_value=setting) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({BSH_POWER_STATE: {"value": BSH_POWER_ON}}) - with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, + appliance_ha_id: str, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "switch.washer_program_mix" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] issue_id = f"deprecated_program_switch_{entity_id}" assert await async_setup_component( @@ -539,7 +645,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 1401e07b05a..95f9ddeba80 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -1,21 +1,19 @@ """Tests for home_connect time entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from datetime import time -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError import pytest -from homeassistant.components.home_connect.const import ATTR_VALUE from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - from tests.common import MockConfigEntry @@ -26,114 +24,98 @@ def platforms() -> list[str]: async def test_time( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test time entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) @pytest.mark.parametrize( - ("entity_id", "setting_key", "setting_value", "expected_state"), + ("entity_id", "setting_key"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: 59}, - str(time(second=59)), - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: None}, - "unknown", - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - None, - "unknown", + SettingKey.BSH_COMMON_ALARM_CLOCK, ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - setting_value: dict, - expected_state: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test time entity functionality.""" - get_appliances.return_value = [appliance] - appliance.status.update({setting_key: setting_value}) - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, expected_state) - new_value = 30 - assert hass.states.get(entity_id).state != new_value + value = 30 + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state != value await hass.services.async_call( TIME_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - ATTR_TIME: time(second=new_value), + ATTR_TIME: time(second=value), }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(time(second=value))) -@pytest.mark.parametrize("problematic_appliance", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", + SettingKey.BSH_COMMON_ALARM_CLOCK, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test time entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=30, + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -147,4 +129,4 @@ async def test_time_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 0aed3dc929e..4facd1695c5 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -6,7 +6,7 @@ import pytest import voluptuous as vol import yaml -from homeassistant import config +from homeassistant import config, core as ha from homeassistant.components.homeassistant import ( ATTR_ENTRY_ID, ATTR_SAFE_MODE, @@ -30,7 +30,6 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import entity, entity_registry as er diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index fe4fb53962a..0d4294ca16f 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index c3117bbb660..f6478e9dda0 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 40f62baa5e7..9a4f41d08e1 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index 7138fd7dd02..ffce8cd476b 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -11,7 +11,7 @@ from homeassistant.components.homeassistant.triggers import time_pattern from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index c1870cecd9c..5bad7aa8f39 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, Event, HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 0d19763e4c7..141141e7f15 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -42,7 +42,7 @@ from homeassistant.const import ( STATE_OPEN, ) from homeassistant.core import Event, HomeAssistant, split_entity_id -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index b94a267104b..e2aaf58d63e 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -32,7 +32,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index eea3f4b67f2..4e787f305b6 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -9,7 +9,7 @@ from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.light.conftest import mock_light_profiles # noqa: F401 diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index a16cd052c87..a71465716c4 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -12,7 +12,7 @@ from homeassistant.components.homekit_controller.connection import ( MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..common import Helper, setup_accessories_from_file, setup_test_accessories diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index b540ebac91a..f9c5e617904 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -3,11 +3,18 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.models import CombinedModels, Device, Measurement, State, System +from homewizard_energy.models import ( + CombinedModels, + Device, + Measurement, + State, + System, + Token, +) import pytest from homeassistant.components.homewizard.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, get_fixture_path, load_json_object_fixture @@ -65,6 +72,59 @@ def mock_homewizardenergy( yield client +@pytest.fixture +def mock_homewizardenergy_v2( + device_fixture: str, +) -> MagicMock: + """Return a mock bridge.""" + with ( + patch( + "homeassistant.components.homewizard.HomeWizardEnergyV2", + autospec=True, + ) as homewizard, + patch( + "homeassistant.components.homewizard.config_flow.HomeWizardEnergyV2", + new=homewizard, + ), + ): + client = homewizard.return_value + + client.combined.return_value = CombinedModels( + device=Device.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/device.json", DOMAIN) + ), + measurement=Measurement.from_dict( + load_json_object_fixture( + f"v2/{device_fixture}/measurement.json", DOMAIN + ) + ), + state=( + State.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/state.json", DOMAIN) + ) + if get_fixture_path(f"v2/{device_fixture}/state.json", DOMAIN).exists() + else None + ), + system=( + System.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/system.json", DOMAIN) + ) + if get_fixture_path(f"v2/{device_fixture}/system.json", DOMAIN).exists() + else None + ), + ) + + # device() call is used during configuration flow + client.device.return_value = client.combined.return_value.device + + # Authorization flow is used during configuration flow + client.get_token.return_value = Token.from_dict( + load_json_object_fixture("v2/generic/token.json", DOMAIN) + ).token + + yield client + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" @@ -90,6 +150,20 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_v2() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Device", + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", + }, + unique_id="HWE-P1_5c2fafabcdef", + ) + + @pytest.fixture async def init_integration( hass: HomeAssistant, diff --git a/tests/components/homewizard/fixtures/HWE-BAT/data.json b/tests/components/homewizard/fixtures/HWE-BAT/data.json new file mode 100644 index 00000000000..490120e7ffd --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/data.json @@ -0,0 +1,12 @@ +{ + "wifi_ssid": "simulating v1 support", + "wifi_strength": 100, + "total_power_import_kwh": 123.456, + "total_power_export_kwh": 123.456, + "active_power_w": 123, + "active_voltage_v": 230, + "active_current_a": 1.5, + "active_frequency_hz": 50, + "state_of_charge_pct": 50, + "cycles": 123 +} diff --git a/tests/components/homewizard/fixtures/HWE-BAT/device.json b/tests/components/homewizard/fixtures/HWE-BAT/device.json new file mode 100644 index 00000000000..c551dc34c91 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-BAT", + "product_name": "Plug-In Battery", + "serial": "5c2fafabcdef", + "firmware_version": "1.00", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-BAT/system.json b/tests/components/homewizard/fixtures/HWE-BAT/system.json new file mode 100644 index 00000000000..b4094f497cb --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/system.json @@ -0,0 +1,7 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "status_led_brightness_pct": 100 +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/device.json b/tests/components/homewizard/fixtures/v2/HWE-P1/device.json new file mode 100644 index 00000000000..2dc3f0692a2 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "5c2fafabcdef", + "firmware_version": "4.19", + "api_version": "2.0.0" +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json b/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json new file mode 100644 index 00000000000..2004b0cd37f --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json @@ -0,0 +1,48 @@ +{ + "protocol_version": 50, + "meter_model": "ISKRA 2M550T-101", + "unique_id": "4E6576657220476F6E6E61204C657420596F7520446F776E", + "timestamp": "2024-06-28T14:12:34", + "tariff": 2, + "energy_import_kwh": 13779.338, + "energy_import_t1_kwh": 10830.511, + "energy_import_t2_kwh": 2948.827, + "energy_export_kwh": 1234.567, + "energy_export_t1_kwh": 234.567, + "energy_export_t2_kwh": 1000, + "power_w": -543, + "power_l1_w": -676, + "power_l2_w": 133, + "power_l3_w": 0, + "current_a": 6, + "current_l1_a": -4, + "current_l2_a": 2, + "current_l3_a": 0, + "voltage_sag_l1_count": 1, + "voltage_sag_l2_count": 1, + "voltage_sag_l3_count": 0, + "voltage_swell_l1_count": 0, + "voltage_swell_l2_count": 0, + "voltage_swell_l3_count": 0, + "any_power_fail_count": 4, + "long_power_fail_count": 5, + "average_power_15m_w": 123.0, + "monthly_power_peak_w": 1111.0, + "monthly_power_peak_timestamp": "2024-06-04T10:11:22", + "external": [ + { + "unique_id": "4E6576657220676F6E6E612072756E2061726F756E64", + "type": "gas_meter", + "timestamp": "2024-06-28T14:00:00", + "value": 2569.646, + "unit": "m3" + }, + { + "unique_id": "616E642064657365727420796F75", + "type": "water_meter", + "timestamp": "2024-06-28T14:05:00", + "value": 123.456, + "unit": "m3" + } + ] +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/system.json b/tests/components/homewizard/fixtures/v2/HWE-P1/system.json new file mode 100644 index 00000000000..38bcaeeb584 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/system.json @@ -0,0 +1,8 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "status_led_brightness_pct": 100, + "api_v1_enabled": true +} diff --git a/tests/components/homewizard/fixtures/v2/generic/token.json b/tests/components/homewizard/fixtures/v2/generic/token.json new file mode 100644 index 00000000000..8fa1e9cb8d1 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/generic/token.json @@ -0,0 +1,4 @@ +{ + "token": "00112233445566778899aabbccddeeff", + "name": "local/new_user" +} diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index b8cf98d9211..2545f674bbd 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -1,9 +1,100 @@ # serializer version: 1 +# name: test_diagnostics[HWE-BAT] + dict({ + 'data': dict({ + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '1.00', + 'id': '**REDACTED**', + 'model_name': 'Plug-In Battery', + 'product_name': 'Plug-In Battery', + 'product_type': 'HWE-BAT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ + 'active_liter_lpm': None, + 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': 1.5, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': 123, + 'energy_export_kwh': 123.456, + 'energy_export_t1_kwh': None, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 123.456, + 'energy_import_t1_kwh': None, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, + 'external_devices': None, + 'frequency_hz': 50.0, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': None, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 123.0, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': 50.0, + 'tariff': None, + 'timestamp': None, + 'total_liter_m3': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'voltage_v': 230.0, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 100, + }), + 'state': None, + 'system': dict({ + 'api_v1_enabled': None, + 'cloud_enabled': False, + 'status_led_brightness_pct': 100, + 'uptime_s': 356, + 'wifi_rssi_db': -77, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', + 'serial': '**REDACTED**', + }), + }) +# --- # name: test_diagnostics[HWE-KWH1] dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 1-phase', @@ -79,6 +170,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -93,7 +185,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 3-phase', @@ -169,6 +261,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -183,7 +276,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '4.19', 'id': '**REDACTED**', 'model_name': 'Wi-Fi P1 Meter', @@ -295,6 +388,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, }), }), 'entry': dict({ @@ -309,7 +403,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.03', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Energy Socket', @@ -389,6 +483,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 94, }), }), 'entry': dict({ @@ -403,7 +498,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '4.07', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Energy Socket', @@ -483,6 +578,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, }), }), 'entry': dict({ @@ -497,7 +593,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '2.03', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Watermeter', @@ -573,6 +669,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 84, }), }), 'entry': dict({ @@ -587,7 +684,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 1-phase', @@ -663,6 +760,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -677,7 +775,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 3-phase', @@ -753,6 +851,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 31a949ca7bd..692383b4794 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -1,4 +1,787 @@ # serializer version: 1 +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_battery_cycles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery cycles', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cycles', + 'unique_id': 'HWE-P1_5c2fafabcdef_cycles', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Battery cycles', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.device_battery_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_charge_pct', + 'unique_id': 'HWE-P1_5c2fafabcdef_state_of_charge_pct', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Device State of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': 'HWE-P1_5c2fafabcdef_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Device Uptime', + }), + 'context': , + 'entity_id': 'sensor.device_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-28T21:39:04+00:00', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index b2ae7bd45e0..c39853c3f9a 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -1,15 +1,20 @@ """Test the homewizard config flow.""" from ipaddress import ip_address -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +from homewizard_energy.errors import ( + DisabledError, + RequestError, + UnauthorizedError, + UnsupportedError, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.homewizard.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -225,10 +230,10 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No type="", name="", properties={ - # "api_enabled": "1", --> removed + "api_enabled": "1", "path": "/api/v1", "product_name": "P1 meter", - "product_type": "HWE-P1", + # "product_type": "HWE-P1", --> removed "serial": "5c2fafabcdef", }, ), @@ -238,32 +243,6 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No assert result["reason"] == "invalid_discovery_parameters" -async def test_discovery_invalid_api(hass: HomeAssistant) -> None: - """Test discovery detecting invalid_api.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - "api_enabled": "1", - "path": "/api/not_v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "5c2fafabcdef", - }, - ), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_api_version" - - async def test_dhcp_discovery_updates_entry( hass: HomeAssistant, mock_homewizardenergy: MagicMock, @@ -338,6 +317,32 @@ async def test_dhcp_discovery_ignores_unknown( assert result.get("reason") == "unknown" +async def test_dhcp_discovery_aborts_for_v2_api( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery aborts when v2 API is detected. + + DHCP discovery requires authorization which is not yet implemented + """ + mock_homewizardenergy.device.side_effect = UnauthorizedError + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.0.0.127", + hostname="HW-p1meter-aabbcc", + macaddress="5c2fafabcdef", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "unsupported_api_version" + + async def test_discovery_flow_updates_new_ip( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -455,12 +460,12 @@ async def test_reauth_flow( result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == "reauth_enable_api" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["reason"] == "reauth_enable_api_successful" async def test_reauth_error( @@ -475,7 +480,7 @@ async def test_reauth_error( result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == "reauth_enable_api" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -609,3 +614,222 @@ async def test_reconfigure_cannot_connect( # changed entry assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" + + +### TESTS FOR V2 IMPLEMENTATION ### + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_flow_works_with_v2_api_support( + hass: HomeAssistant, + mock_homewizardenergy_v2: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow accepts user configuration and triggers authorization when detected v2 support.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate v2 support but not authorized + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + # Simulate user authorizing + mock_homewizardenergy_v2.device.side_effect = None + mock_homewizardenergy_v2.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_flow_detects_failed_user_authorization( + hass: HomeAssistant, + mock_homewizardenergy_v2: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow accepts user configuration and detects failed button press by user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate v2 support but not authorized + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + assert result["errors"] == {"base": "authorization_failed"} + + # Restore normal functionality + mock_homewizardenergy_v2.device.side_effect = None + mock_homewizardenergy_v2.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_updates_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test reauth flow token is updated.""" + + mock_config_entry_v2.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_v2.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm_update_token" + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.return_value = "cool_new_token" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify that the token was updated + await hass.async_block_till_done() + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data.get(CONF_TOKEN) + == "cool_new_token" + ) + assert len(mock_setup_entry.mock_calls) == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_handles_user_not_pressing_button( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test reauth flow token is updated.""" + + mock_config_entry_v2.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_v2.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm_update_token" + assert result["errors"] is None + + # Simulate button not being pressed + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "authorization_failed"} + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_new_token" + + # Successful reauth + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify that the token was updated + await hass.async_block_till_done() + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data.get(CONF_TOKEN) + == "cool_new_token" + ) + assert len(mock_setup_entry.mock_calls) == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_with_v2_api_ask_authorization( + hass: HomeAssistant, + # mock_setup_entry: AsyncMock, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test discovery detecting missing discovery info.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=443, + hostname="p1meter-abcdef.local.", + type="", + name="", + properties={ + "api_version": "2.0.0", + "id": "appliance/p1dongle/5c2fafabcdef", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "5c2fafabcdef", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_token" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == "cool_token" diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index e3d7f4e6da9..c7063d497c3 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -21,6 +21,7 @@ from tests.typing import ClientSessionGenerator "SDM630", "HWE-KWH1", "HWE-KWH3", + "HWE-BAT", ], ) async def test_diagnostics( diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index ed4bad8b2e8..77366da84c5 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from homewizard_energy.errors import DisabledError +from homewizard_energy.errors import DisabledError, UnauthorizedError import pytest from homeassistant.components.homewizard.const import DOMAIN @@ -14,12 +14,12 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed -async def test_load_unload( +async def test_load_unload_v1( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homewizardenergy: MagicMock, ) -> None: - """Test loading and unloading of integration.""" + """Test loading and unloading of integration with v1 config.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -33,6 +33,25 @@ async def test_load_unload( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +async def test_load_unload_v2( + hass: HomeAssistant, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test loading and unloading of integration with v2 config.""" + mock_config_entry_v2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.LOADED + assert len(mock_homewizardenergy_v2.combined.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.NOT_LOADED + + async def test_load_failed_host_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -64,7 +83,7 @@ async def test_load_detect_api_disabled( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" + assert flow.get("step_id") == "reauth_enable_api" assert flow.get("handler") == DOMAIN assert "context" in flow @@ -72,6 +91,31 @@ async def test_load_detect_api_disabled( assert flow["context"].get("entry_id") == mock_config_entry.entry_id +async def test_load_detect_invalid_token( + hass: HomeAssistant, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test setup detects invalid token.""" + mock_homewizardenergy_v2.combined.side_effect = UnauthorizedError() + mock_config_entry_v2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm_update_token" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry_v2.entry_id + + @pytest.mark.usefixtures("mock_homewizardenergy") async def test_load_removes_reauth_flow( hass: HomeAssistant, @@ -128,5 +172,5 @@ async def test_disablederror_reloads_integration( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" + assert flow.get("step_id") == "reauth_enable_api" assert flow.get("handler") == DOMAIN diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index b668043608c..67e51cbafe2 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/homewizard/test_repair.py b/tests/components/homewizard/test_repair.py new file mode 100644 index 00000000000..a59d6f415dd --- /dev/null +++ b/tests/components/homewizard/test_repair.py @@ -0,0 +1,82 @@ +"""Test the homewizard config flow.""" + +from unittest.mock import MagicMock, patch + +from homewizard_energy.errors import DisabledError + +from homeassistant.components.homewizard.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +async def test_repair_acquires_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, + mock_homewizardenergy_v2: MagicMock, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair flow is able to obtain and use token.""" + + assert await async_setup_component(hass, "repairs", {}) + await async_process_repairs_platforms(hass) + client = await hass_client() + + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.homewizard.has_v2_api", return_value=True): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Get active repair flow + issue_id = f"migrate_to_v2_api_{mock_config_entry.entry_id}" + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + + assert issue.data.get("entry_id") == mock_config_entry.entry_id + + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "authorize" + + # Simulate user not pressing the button + result = await process_repair_fix_flow(client, flow_id, json={}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "authorize" + assert result["errors"] == {"base": "authorization_failed"} + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_token" + result = await process_repair_fix_flow(client, flow_id, json={}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert mock_config_entry.data[CONF_TOKEN] == "cool_token" + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 128a3de2ebf..94a59551eb4 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.homewizard.const import UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -19,6 +19,7 @@ pytestmark = [ ] +@pytest.mark.freeze_time("2025-01-28 21:45:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("device_fixture", "entity_ids"), @@ -291,6 +292,20 @@ pytestmark = [ "sensor.water_meter_water", ], ), + ( + "HWE-BAT", + [ + "sensor.device_battery_cycles", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power", + "sensor.device_state_of_charge", + "sensor.device_uptime", + "sensor.device_voltage", + ], + ), ], ) async def test_sensors( @@ -431,6 +446,15 @@ async def test_sensors( "sensor.device_wi_fi_strength", ], ), + ( + "HWE-BAT", + [ + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_uptime", + "sensor.device_voltage", + ], + ), ], ) async def test_disabled_by_default_sensors( @@ -492,6 +516,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_apparent_power", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -521,8 +546,10 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -543,6 +570,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_2", "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -568,8 +596,10 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -590,6 +620,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_apparent_power", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -623,7 +654,9 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -644,6 +677,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -670,8 +704,10 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -688,6 +724,7 @@ async def test_external_sensors_unreachable( "SDM630", [ "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -706,8 +743,10 @@ async def test_external_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -729,6 +768,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -755,8 +795,10 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -773,6 +815,7 @@ async def test_external_sensors_unreachable( "HWE-KWH3", [ "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -791,8 +834,10 @@ async def test_external_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -806,6 +851,54 @@ async def test_external_sensors_unreachable( "sensor.device_water_usage", ], ), + ( + "HWE-BAT", + [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_dsmr_version", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_factor", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_1", + "sensor.device_power_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_water_usage", + ], + ), ], ) async def test_entities_not_created_for_device( diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index ccf99ee27fa..ae9b7653b6d 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -18,7 +18,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/html5/test_config_flow.py b/tests/components/html5/test_config_flow.py index ca0b3da0389..3cde435771e 100644 --- a/tests/components/html5/test_config_flow.py +++ b/tests/components/html5/test_config_flow.py @@ -17,7 +17,7 @@ from homeassistant.components.html5.issues import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir MOCK_CONF = { ATTR_VAPID_EMAIL: "test@example.com", diff --git a/tests/components/html5/test_init.py b/tests/components/html5/test_init.py index 290cb381296..840890f18d1 100644 --- a/tests/components/html5/test_init.py +++ b/tests/components/html5/test_init.py @@ -1,7 +1,7 @@ """Test the HTML5 setup.""" from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index 0d9388907a9..f602a8f3807 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -8,7 +8,7 @@ from unittest.mock import mock_open, patch from aiohttp.hdrs import AUTHORIZATION from aiohttp.test_utils import TestClient -import homeassistant.components.html5.notify as html5 +from homeassistant.components.html5 import notify as html5 from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 3bb1f8c2551..e1b2b2bff61 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -24,7 +24,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index cf159c23bae..5a48e08e5db 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 2de7fb1da9a..ad3a97fa6e0 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -63,7 +63,7 @@ def mock_pydrawise( controller_water_use_summary: ControllerWaterUseSummary, ) -> Generator[AsyncMock]: """Mock Hydrawise.""" - with patch("pydrawise.client.Hydrawise", autospec=True) as mock_pydrawise: + with patch("pydrawise.hybrid.HybridClient", autospec=True) as mock_pydrawise: user.controllers = [controller] controller.sensors = sensors mock_pydrawise.return_value.get_user.return_value = user @@ -76,8 +76,8 @@ def mock_pydrawise( @pytest.fixture def mock_auth() -> Generator[AsyncMock]: - """Mock pydrawise Auth.""" - with patch("pydrawise.auth.Auth", autospec=True) as mock_auth: + """Mock pydrawise HybridAuth.""" + with patch("pydrawise.auth.HybridAuth", autospec=True) as mock_auth: yield mock_auth.return_value @@ -215,6 +215,7 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_USERNAME: "asfd@asdf.com", CONF_PASSWORD: "__password__", + CONF_API_KEY: "abc123", }, unique_id="hydrawise-customerid", version=1, diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index cf723d885e1..594286b7f01 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -9,7 +9,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.hydrawise.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -35,7 +35,11 @@ async def test_form( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}, + { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) mock_pydrawise.get_user.return_value = user await hass.async_block_till_done() @@ -45,9 +49,10 @@ async def test_form( assert result["data"] == { CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", } assert len(mock_setup_entry.mock_calls) == 1 - mock_auth.token.assert_awaited_once_with() + mock_auth.check.assert_awaited_once_with() mock_pydrawise.get_user.assert_awaited_once_with(fetch_zones=False) @@ -60,7 +65,11 @@ async def test_form_api_error( init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -77,11 +86,18 @@ async def test_form_auth_connect_timeout( hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock ) -> None: """Test we handle connection timeout errors.""" - mock_auth.token.side_effect = TimeoutError + mock_auth.check.side_effect = TimeoutError init_result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={ + "source": config_entries.SOURCE_USER, + }, ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -89,7 +105,7 @@ async def test_form_auth_connect_timeout( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "timeout_connect"} - mock_auth.token.reset_mock(side_effect=True) + mock_auth.check.reset_mock(side_effect=True) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -102,7 +118,11 @@ async def test_form_client_connect_timeout( init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -120,19 +140,23 @@ async def test_form_not_authorized_error( hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock ) -> None: """Test we handle API errors.""" - mock_auth.token.side_effect = NotAuthorizedError + mock_auth.check.side_effect = NotAuthorizedError init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} - mock_auth.token.reset_mock(side_effect=True) + mock_auth.check.reset_mock(side_effect=True) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -150,6 +174,7 @@ async def test_reauth( data={ CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "bad-password", + CONF_API_KEY: "__api-key__", }, unique_id="hydrawise-12345", ) @@ -165,7 +190,11 @@ async def test_reauth( mock_pydrawise.get_user.return_value = user result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: "__password__"} + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) await hass.async_block_till_done() @@ -183,6 +212,7 @@ async def test_reauth_fails( data={ CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "bad-password", + CONF_API_KEY: "__api-key__", }, unique_id="hydrawise-12345", ) @@ -191,18 +221,26 @@ async def test_reauth_fails( result = await mock_config_entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" - mock_auth.token.side_effect = NotAuthorizedError + mock_auth.check.side_effect = NotAuthorizedError result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: "__password__"} + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} - mock_auth.token.reset_mock(side_effect=True) + mock_auth.check.reset_mock(side_effect=True) mock_pydrawise.get_user.return_value = user result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: "__password__"} + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/ign_sismologia/test_geo_location.py b/tests/components/ign_sismologia/test_geo_location.py index c26eae28086..2f946459bfe 100644 --- a/tests/components/ign_sismologia/test_geo_location.py +++ b/tests/components/ign_sismologia/test_geo_location.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 3e7c8f2fb91..6ff6d925d7e 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -6,8 +6,7 @@ from unittest.mock import PropertyMock, patch import pytest -from homeassistant.components import http -import homeassistant.components.image_processing as ip +from homeassistant.components import http, image_processing as ip from homeassistant.const import ATTR_ENTITY_PICTURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 07390cd9571..ba4a6bdf198 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 522006b0847..90a3a90c451 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -3,7 +3,7 @@ from datetime import datetime from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util MOCK_USER_INPUT = { CONF_NAME: "Home", diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index 7961b79676b..5ae11d8f850 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import NOW, PRAYER_TIMES diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index 440bffc2256..ba0a2b4835e 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -6,7 +6,7 @@ from datetime import datetime from freezegun import freeze_time as alter_time # noqa: F401 from homeassistant.components import jewish_calendar -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LatLng = namedtuple("_LatLng", ["lat", "lng"]) # noqa: PYI024 diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 8abaaecb77d..5cfaaedfc72 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.jewish_calendar.const import ( from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import alter_time, make_jerusalem_test_params, make_nyc_test_params diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 4897ef7749b..aac0f583b05 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.jewish_calendar.const import ( from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import alter_time, make_jerusalem_test_params, make_nyc_test_params diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 9e46845e1cb..a664b91393d 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -55,7 +55,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "kitchen_sink.syncer", "name": "syncer"}, + ], } config_entry = hass.config_entries.async_entries(DOMAIN)[0] @@ -66,7 +69,9 @@ async def test_agents_info( response = await client.receive_json() assert response["success"] - assert response["result"] == {"agents": [{"agent_id": "backup.local"}]} + assert response["result"] == { + "agents": [{"agent_id": "backup.local", "name": "local"}] + } await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -76,7 +81,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "kitchen_sink.syncer", "name": "syncer"}, + ], } @@ -94,7 +102,7 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], - "agent_ids": ["kitchen_sink.syncer"], + "agents": {"kitchen_sink.syncer": {"protected": False, "size": 1234}}, "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00Z", @@ -103,8 +111,6 @@ async def test_agents_list_backups( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Kitchen sink syncer", - "protected": False, - "size": 1234, "with_automatic_settings": None, } ] @@ -177,7 +183,7 @@ async def test_agents_upload( assert len(backup_list) == 2 assert backup_list[1] == { "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], - "agent_ids": ["kitchen_sink.syncer"], + "agents": {"kitchen_sink.syncer": {"protected": False, "size": 0.0}}, "backup_id": "test-backup", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -186,8 +192,6 @@ async def test_agents_upload( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0.0, "with_automatic_settings": False, } diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index b832577a48a..7338c1dca99 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index dbb8d2ee832..4b58801a8a0 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -1,10 +1,19 @@ """Test KNX binary sensor.""" from datetime import timedelta +from typing import Any from freezegun.api import FrozenDateTimeFactory +import pytest -from homeassistant.components.knx.const import CONF_STATE_ADDRESS, CONF_SYNC_STATE +from homeassistant.components.knx.const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, + CONF_INVERT, + CONF_RESET_AFTER, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, +) from homeassistant.components.knx.schema import BinarySensorSchema from homeassistant.const import ( CONF_ENTITY_CATEGORY, @@ -12,10 +21,12 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, EntityCategory, + Platform, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import ( @@ -60,7 +71,7 @@ async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: { CONF_NAME: "test_invert", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_INVERT: True, + CONF_INVERT: True, }, ] } @@ -113,7 +124,7 @@ async def test_binary_sensor_ignore_internal_state( { CONF_NAME: "test_ignore", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE: True, + CONF_IGNORE_INTERNAL_STATE: True, CONF_SYNC_STATE: False, }, ] @@ -156,7 +167,7 @@ async def test_binary_sensor_counter( { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_CONTEXT_TIMEOUT: context_timeout, + CONF_CONTEXT_TIMEOUT: context_timeout, CONF_SYNC_STATE: False, }, ] @@ -220,7 +231,7 @@ async def test_binary_sensor_reset( { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_RESET_AFTER: 1, + CONF_RESET_AFTER: 1, CONF_SYNC_STATE: False, }, ] @@ -279,7 +290,7 @@ async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: { CONF_NAME: "test", CONF_STATE_ADDRESS: _ADDRESS, - BinarySensorSchema.CONF_INVERT: True, + CONF_INVERT: True, CONF_SYNC_STATE: False, }, ] @@ -295,3 +306,37 @@ async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: await knx.receive_write(_ADDRESS, True) state = hass.states.get("binary_sensor.test") assert state.state is STATE_OFF + + +@pytest.mark.parametrize( + ("knx_data"), + [ + { + "ga_sensor": {"state": "2/2/2"}, + "sync_state": True, + }, + { + "ga_sensor": {"state": "2/2/2"}, + "sync_state": True, + "invert": True, + }, + ], +) +async def test_binary_sensor_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_data: dict[str, Any], +) -> None: + """Test creating a binary sensor.""" + await knx.setup_integration({}) + await create_ui_entity( + platform=Platform.BINARY_SENSOR, + entity_data={"name": "test"}, + knx_data=knx_data, + ) + # created entity sends read-request to KNX bus + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", not knx_data.get("invert")) + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 8fb348f1724..b5a90428ef2 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -850,3 +850,91 @@ async def test_climate_humidity(hass: HomeAssistant, knx: KNXTestKit) -> None: HVACMode.HEAT, current_humidity=45.6, ) + + +async def test_swing(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate swing.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_SWING_ADDRESS: "1/2/6", + ClimateSchema.CONF_SWING_STATE_ADDRESS: "1/2/7", + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", True) + knx.assert_state( + "climate.test", + HVACMode.HEAT, + swing_mode="on", + swing_modes=["on", "off"], + ) + + # turn off + await hass.services.async_call( + "climate", + "set_swing_mode", + {"entity_id": "climate.test", "swing_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", False) + knx.assert_state("climate.test", HVACMode.HEAT, swing_mode="off") + + +async def test_horizontal_swing(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate horizontal swing.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_SWING_HORIZONTAL_ADDRESS: "1/2/6", + ClimateSchema.CONF_SWING_HORIZONTAL_STATE_ADDRESS: "1/2/7", + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", True) + knx.assert_state( + "climate.test", + HVACMode.HEAT, + swing_horizontal_mode="on", + swing_horizontal_modes=["on", "off"], + ) + + # turn off + await hass.services.async_call( + "climate", + "set_swing_horizontal_mode", + {"entity_id": "climate.test", "swing_horizontal_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", False) + knx.assert_state("climate.test", HVACMode.HEAT, swing_horizontal_mode="off") diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index a2245e721c5..230a2562282 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 723f9738e1c..9e2eae482d2 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -256,7 +256,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1047', + 'state': '2387', }) # --- # name: test_sensors[sensor.gs012345_total_flushes_made-entry] diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 64ebe22e98b..3e73b710942 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index 2f64f421b93..7d636f546c4 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -15,8 +15,7 @@ from homeassistant.components.lcn.helpers import get_device_connection from homeassistant.components.script import scripts_with_entity from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import MockConfigEntry, init_integration diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index cd97e3484e3..c9eda40fdba 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -31,7 +31,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import ( diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index f7686f815fe..829d1df54f3 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -1,12 +1,42 @@ """Tests for the LetPot integration.""" -from letpot.models import AuthenticationInfo +import datetime + +from letpot.models import AuthenticationInfo, LetPotDeviceStatus + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + AUTHENTICATION = AuthenticationInfo( access_token="access_token", - access_token_expires=0, + access_token_expires=1738368000, # 2025-02-01 00:00:00 GMT refresh_token="refresh_token", - refresh_token_expires=0, + refresh_token_expires=1740441600, # 2025-02-25 00:00:00 GMT user_id="a1b2c3d4e5f6a1b2c3d4e5f6", email="email@example.com", ) + +STATUS = LetPotDeviceStatus( + light_brightness=500, + light_mode=1, + light_schedule_end=datetime.time(12, 10), + light_schedule_start=datetime.time(12, 0), + online=True, + plant_days=1, + pump_mode=1, + pump_nutrient=None, + pump_status=0, + raw=[77, 0, 1, 18, 98, 1, 0, 0, 1, 1, 1, 0, 1, 12, 0, 12, 10, 1, 244, 0, 0, 0], + system_on=True, + system_sound=False, + system_state=0, +) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 4cd7ef442a6..7971bca50ae 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from letpot.models import LetPotDevice import pytest from homeassistant.components.letpot.const import ( @@ -14,7 +15,7 @@ from homeassistant.components.letpot.const import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL -from . import AUTHENTICATION +from . import AUTHENTICATION, STATUS from tests.common import MockConfigEntry @@ -28,6 +29,49 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def mock_client() -> Generator[AsyncMock]: + """Mock a LetPotClient.""" + with ( + patch( + "homeassistant.components.letpot.LetPotClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.letpot.config_flow.LetPotClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.login.return_value = AUTHENTICATION + client.refresh_token.return_value = AUTHENTICATION + client.get_devices.return_value = [ + LetPotDevice( + serial_number="LPH21ABCD", + name="Garden", + device_type="LPH21", + is_online=True, + is_remote=False, + ) + ] + yield client + + +@pytest.fixture +def mock_device_client() -> Generator[AsyncMock]: + """Mock a LetPotDeviceClient.""" + with patch( + "homeassistant.components.letpot.coordinator.LetPotDeviceClient", + autospec=True, + ) as mock_device_client: + device_client = mock_device_client.return_value + device_client.device_model_code = "LPH21" + device_client.device_model_name = "LetPot Air" + device_client.get_current_status.return_value = STATUS + device_client.last_status.return_value = STATUS + yield device_client + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" diff --git a/tests/components/letpot/test_config_flow.py b/tests/components/letpot/test_config_flow.py index 425298dc231..a659b235213 100644 --- a/tests/components/letpot/test_config_flow.py +++ b/tests/components/letpot/test_config_flow.py @@ -2,7 +2,7 @@ import dataclasses from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException import pytest @@ -39,7 +39,9 @@ def _assert_result_success(result: Any) -> None: assert result["result"].unique_id == AUTHENTICATION.user_id -async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_full_flow( + hass: HomeAssistant, mock_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: """Test full flow with success.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -47,18 +49,13 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=AUTHENTICATION, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) _assert_result_success(result) assert len(mock_setup_entry.mock_calls) == 1 @@ -74,6 +71,7 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No ) async def test_flow_exceptions( hass: HomeAssistant, + mock_client: AsyncMock, mock_setup_entry: AsyncMock, exception: Exception, error: str, @@ -83,41 +81,37 @@ async def test_flow_exceptions( DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) + mock_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} # Retry to show recovery. - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=AUTHENTICATION, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + mock_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) _assert_result_success(result) assert len(mock_setup_entry.mock_calls) == 1 async def test_flow_duplicate( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test flow aborts when trying to add a previously added account.""" mock_config_entry.add_to_hass(hass) @@ -130,18 +124,13 @@ async def test_flow_duplicate( assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=AUTHENTICATION, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -149,7 +138,10 @@ async def test_flow_duplicate( async def test_reauth_flow( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow with success.""" mock_config_entry.add_to_hass(hass) @@ -163,15 +155,11 @@ async def test_reauth_flow( access_token="new_access_token", refresh_token="new_refresh_token", ) - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -196,6 +184,7 @@ async def test_reauth_flow( ) async def test_reauth_exceptions( hass: HomeAssistant, + mock_client: AsyncMock, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, @@ -208,14 +197,11 @@ async def test_reauth_exceptions( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) + mock_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} @@ -226,15 +212,12 @@ async def test_reauth_exceptions( access_token="new_access_token", refresh_token="new_refresh_token", ) - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + mock_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -250,7 +233,10 @@ async def test_reauth_exceptions( async def test_reauth_different_user_id_new( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow with different, new user ID updating the existing entry.""" mock_config_entry.add_to_hass(hass) @@ -263,15 +249,11 @@ async def test_reauth_different_user_id_new( assert result["step_id"] == "reauth_confirm" updated_auth = dataclasses.replace(AUTHENTICATION, user_id="new_user_id") - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -289,7 +271,10 @@ async def test_reauth_different_user_id_new( async def test_reauth_different_user_id_existing( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow with different, existing user ID aborting.""" mock_config_entry.add_to_hass(hass) @@ -303,15 +288,11 @@ async def test_reauth_different_user_id_existing( assert result["step_id"] == "reauth_confirm" updated_auth = dataclasses.replace(AUTHENTICATION, user_id="other_user_id") - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/letpot/test_init.py b/tests/components/letpot/test_init.py new file mode 100644 index 00000000000..178227a6506 --- /dev/null +++ b/tests/components/letpot/test_init.py @@ -0,0 +1,96 @@ +"""Test the LetPot integration initialization and setup.""" + +from unittest.mock import MagicMock + +from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.freeze_time("2025-01-31 00:00:00") +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_client.refresh_token.assert_not_called() # Didn't refresh valid token + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_called_once() + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_device_client.disconnect.assert_called_once() + + +@pytest.mark.freeze_time("2025-02-15 00:00:00") +async def test_refresh_authentication_on_load( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test expired access token refreshed when needed to load config entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_client.refresh_token.assert_called_once() + + # Check loading continued as expected after refreshing token + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_called_once() + + +@pytest.mark.freeze_time("2025-03-01 00:00:00") +async def test_refresh_token_error_aborts( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test expired refresh token aborting config entry loading.""" + mock_client.refresh_token.side_effect = LetPotAuthenticationException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + mock_client.refresh_token.assert_called_once() + mock_client.get_devices.assert_not_called() + + +@pytest.mark.parametrize( + ("exception", "config_entry_state"), + [ + (LetPotAuthenticationException, ConfigEntryState.SETUP_ERROR), + (LetPotConnectionException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_get_devices_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test config entry errors if an exception is raised when getting devices.""" + mock_client.get_devices.side_effect = exception + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is config_entry_state + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_not_called() diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index 94e12ffbfa5..2a5c9f0bb18 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockLight diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index 4e8414edabc..ae54bbd2512 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 6d0337f37a5..5bc17ea3e24 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import frame from homeassistant.setup import async_setup_component -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .common import MockLight diff --git a/tests/components/litejet/conftest.py b/tests/components/litejet/conftest.py index 41517acf1e9..975f943d2fa 100644 --- a/tests/components/litejet/conftest.py +++ b/tests/components/litejet/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util @pytest.fixture diff --git a/tests/components/litejet/test_trigger.py b/tests/components/litejet/test_trigger.py index c13fda9068c..de99d701926 100644 --- a/tests/components/litejet/test_trigger.py +++ b/tests/components/litejet/test_trigger.py @@ -11,7 +11,7 @@ import pytest from homeassistant import setup from homeassistant.components import automation from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_init_integration diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index 5cd97e5937d..e60e0cbd36d 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -123,15 +123,9 @@ async def setup_integration( ) entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.litterrobot.coordinator.Account", - return_value=mock_account, - ), - patch( - "homeassistant.components.litterrobot.PLATFORMS_BY_TYPE", - {Robot: (platform_domain,)} if platform_domain else {}, - ), + with patch( + "homeassistant.components.litterrobot.coordinator.Account", + return_value=mock_account, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py index 2eadafb0d0c..caaf832b780 100644 --- a/tests/components/litterrobot/test_config_flow.py +++ b/tests/components/litterrobot/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from pylitterbot import Account from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException +import pytest from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD @@ -15,9 +16,8 @@ from .common import CONF_USERNAME, CONFIG, DOMAIN from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, mock_account) -> None: - """Test we get the form.""" - +async def test_full_flow(hass: HomeAssistant, mock_account) -> None: + """Test full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -34,19 +34,18 @@ async def test_form(hass: HomeAssistant, mock_account) -> None: return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG[DOMAIN] ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == CONFIG[DOMAIN][CONF_USERNAME] - assert result2["data"] == CONFIG[DOMAIN] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONFIG[DOMAIN][CONF_USERNAME] + assert result["data"] == CONFIG[DOMAIN] assert len(mock_setup_entry.mock_calls) == 1 async def test_already_configured(hass: HomeAssistant) -> None: - """Test we handle already configured.""" + """Test already configured case.""" MockConfigEntry( domain=DOMAIN, data=CONFIG[DOMAIN], @@ -62,71 +61,32 @@ async def test_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("side_effect", "connect_errors"), + [ + (Exception, {"base": "unknown"}), + (LitterRobotLoginException, {"base": "invalid_auth"}), + (LitterRobotException, {"base": "cannot_connect"}), + ], +) +async def test_create_entry( + hass: HomeAssistant, mock_account, side_effect, connect_errors +) -> None: + """Test creating an entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=LitterRobotLoginException, + side_effect=side_effect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG[DOMAIN] ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=LitterRobotException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], CONFIG[DOMAIN] - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_error(hass: HomeAssistant) -> None: - """Test we handle unknown error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], CONFIG[DOMAIN] - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: - """Test the reauth flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG[DOMAIN], - ) - entry.add_to_hass(hass) - - result = await entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["errors"] == connect_errors with ( patch( @@ -136,19 +96,19 @@ async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: patch( "homeassistant.components.litterrobot.async_setup_entry", return_value=True, - ) as mock_setup_entry, + ), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[DOMAIN][CONF_PASSWORD]}, + result["flow_id"], CONFIG[DOMAIN] ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert len(mock_setup_entry.mock_calls) == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONFIG[DOMAIN][CONF_USERNAME] + assert result["data"] == CONFIG[DOMAIN] -async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> None: - """Test the reauth flow fails and recovers.""" +async def test_reauth(hass: HomeAssistant, mock_account: Account) -> None: + """Test reauth flow (with fail and recover).""" entry = MockConfigEntry( domain=DOMAIN, data=CONFIG[DOMAIN], diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 0255e0e6a8a..911dfb3b880 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -33,7 +33,6 @@ async def test_vacuum( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_account: MagicMock ) -> None: """Tests the vacuum entity was set up.""" - entity_registry.async_get_or_create( VACUUM_DOMAIN, DOMAIN, @@ -44,7 +43,6 @@ async def test_vacuum( assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID await setup_integration(hass, mock_account, VACUUM_DOMAIN) - assert len(entity_registry.entities) == 1 assert hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) vacuum = hass.states.get(VACUUM_ENTITY_ID) @@ -63,8 +61,6 @@ async def test_no_robots( """Tests the vacuum entity was set up.""" entry = await setup_integration(hass, mock_account_with_no_robots, VACUUM_DOMAIN) - assert not hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) - assert len(entity_registry.entities) == 0 assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 61908faeca6..0720e6d7ded 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -8,7 +8,7 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.template import DATE_STR_FORMAT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( FRIENDLY_NAME, diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 3ecdf2a9bca..7d1c39d10f0 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 68af8c7d482..510034a2172 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -21,7 +21,7 @@ from homeassistant.components.lock import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .conftest import MockLock diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index abb118467f4..b303a34e151 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -16,7 +16,7 @@ from homeassistant.components.recorder.models import ( from homeassistant.core import Context from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util IDX_TO_NAME = dict(enumerate(EventAsRow._fields)) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 841c8ed1247..c62bdcaa824 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -10,6 +10,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import logbook, recorder # pylint: disable-next=hass-component-root-import @@ -40,12 +41,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockRow, mock_humanify diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 50139d0f4f7..7b2550ccc82 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -37,7 +37,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.recorder.common import ( diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index cc80bc08817..bdbe6501470 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -10,8 +10,10 @@ from pylutron_caseta.smartbridge import Smartbridge import pytest from homeassistant import config_entries -from homeassistant.components.lutron_caseta import DOMAIN -import homeassistant.components.lutron_caseta.config_flow as CasetaConfigFlow +from homeassistant.components.lutron_caseta import ( + DOMAIN, + config_flow as CasetaConfigFlow, +) from homeassistant.components.lutron_caseta.const import ( CONF_CA_CERTS, CONF_CERTFILE, diff --git a/tests/components/madvr/test_binary_sensor.py b/tests/components/madvr/test_binary_sensor.py index 469a3225ca0..9ddbc7b3afe 100644 --- a/tests/components/madvr/test_binary_sensor.py +++ b/tests/components/madvr/test_binary_sensor.py @@ -9,7 +9,7 @@ from syrupy import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .conftest import get_update_callback diff --git a/tests/components/madvr/test_remote.py b/tests/components/madvr/test_remote.py index 6fc507534d6..1ddbacdb6e9 100644 --- a/tests/components/madvr/test_remote.py +++ b/tests/components/madvr/test_remote.py @@ -20,7 +20,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .const import ( diff --git a/tests/components/madvr/test_sensor.py b/tests/components/madvr/test_sensor.py index ddc01fc737a..dd1722913f2 100644 --- a/tests/components/madvr/test_sensor.py +++ b/tests/components/madvr/test_sensor.py @@ -10,7 +10,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.madvr.sensor import get_temperature from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .conftest import get_update_callback diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 9fc92cd5458..941d7523220 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component, mock_restore_cache from tests.components.alarm_control_panel import common diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 2b401cb10a0..9bb506b935a 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( assert_setup_component, diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 4e078f86939..d7429f6087d 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -116,6 +116,7 @@ async def integration_fixture( "window_covering_pa_lift", "window_covering_pa_tilt", "window_covering_tilt", + "yandex_smart_socket", ] ) async def matter_devices( diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json index 4d26dfb03aa..3b1ed0043de 100644 --- a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -656,7 +656,7 @@ "1/83/0": ["Off", "Low", "Medium", "High"], "1/83/1": 0, "1/83/2": 0, - "1/83/3": [1, 2], + "1/83/3": [0, 1], "1/83/65532": 3, "1/83/65533": 1, "1/83/65528": [], @@ -673,8 +673,8 @@ "1/86/65528": [], "1/86/65529": [0], "1/86/65531": [4, 5, 65528, 65529, 65531, 65532, 65533], - "1/96/0": null, - "1/96/1": null, + "1/96/0": ["pre-soak", "rinse", "spin"], + "1/96/1": 0, "1/96/3": [ { "0": 0 diff --git a/tests/components/matter/fixtures/nodes/yandex_smart_socket.json b/tests/components/matter/fixtures/nodes/yandex_smart_socket.json new file mode 100644 index 00000000000..26cdf38414f --- /dev/null +++ b/tests/components/matter/fixtures/nodes/yandex_smart_socket.json @@ -0,0 +1,278 @@ +{ + "node_id": 4, + "date_commissioned": "2024-12-05T10:54:31.635203", + "last_interview": "2024-12-05T12:16:52.038776", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 52, 54, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Yandex", + "0/40/2": 5130, + "0/40/3": "YNDX-00540", + "0/40/4": 540, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v0.4", + "0/40/9": 18, + "0/40/10": "8.0.r13402545-18", + "0/40/15": "HP000RM000V4RW", + "0/40/17": true, + "0/40/18": "E4480D32A5480B29", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 17, 18, 19, 65528, 65529, 65531, + 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "**REDACTED**", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "**REDACTED**", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "PAtP8Nse", + "5": ["wKgAEw=="], + "6": ["/oAAAAAAAAA+C0///vDbHg==", "/YrmoeskHZU+C0///vDbHg=="], + "7": 1 + } + ], + "0/51/1": 4, + "0/51/2": 124, + "0/51/3": 0, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/1": 79260, + "0/52/2": 171268, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [1, 2, 65528, 65529, 65531, 65532, 65533], + "0/54/0": "eJoYDvok", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 11, + "0/54/4": -53, + "0/54/65532": 0, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEEvOt9COzrjgf+b8q6FcKeKfbtqJybToVtEF0jiidbqg8FPmTIPTm1kU9hEiE6sd2N/GWSQHRoMi3YNl19h1PM3zcKNQEoARgkAgE2AwQCBAEYMAQUSu0+nQ/nOzrUNECyeBAqGPVu33YwBRS2PEiS/N109emRL3DTMaiWoWrEShgwC0DoGPCGt0HeGYnTS4TS2R7vbNhiFuuIrUQuxY5phP/UXBZosBDQTsnRTbMof18OkeO68MEcLXdIXjBJvBDaP/TsGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEz8nO5tz0gMFM5TW4YjYXGxkhr/UKHZg1rCa21StYqGd0wGaP7a5eMR+2BY20D1b11R7i6teWKnAaW+WqY0vQTjcKNQEpARgkAmAwBBS2PEiS/N109emRL3DTMaiWoWrESjAFFG//dFS5V0Y6/QdSQcC+z7idKKeJGDALQGFBsf7Ecq44e7NN8dCZIoJMUG16rmwD4ZtHtD4JPTxYabEreeblNF2ZDSgbo+A8sfz7Ci37WjznxbEj96vR8MgY", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BLB0QnDldRPfV2xt6Nd/34ja8uaWwvsLYZsF3yCdIwyB/krYZ0u1uBS0FTo7E3iqvN0cDZ7fbhw0OUsKTVZ9Y10=", + "2": 65521, + "3": 1, + "4": 4, + "5": "", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [ + "FTABAQAkAgE3AyYU3a/iASYVn7wdXBgmBKaW1SwkBQA3BiYU3a/iASYVn7wdXBgkBwEkCAEwCUEE/OyhHiUZDgJ7iUVCKouxsZgI0DGBcK8E+vbDIHD5gfeFPNuT5sXN8aHlsEl7fZhfjbdEbIFudeJKIr5uf7+PLTcKNQEpARgkAmAwBBQtr6wAOFJ7UJLwYUKvomZh5wPaszAFFC2vrAA4UntQkvBhQq+iZmHnA9qzGDALQM5/1ziQdNcMURJqGH+j9wt7w/wPyeq8zf+u3FGgmmfhBSouJw4f+TIJLk7m/eQD0p2Q5rSDEuuwI2VBTxxeuWgY", + "FTABAQAkAgE3AycUe5hjm9Wdt4YmFewk8wUYJgTGN00tJAUANwYnFHuYY5vVnbeGJhXsJPMFGCQHASQIATAJQQR56PnGPW5p1dXhHDSVnjoah8C2+JYHzPAm5tvYgup9gf7DukH2TxxLdDEaBdD4hgQj/R8hrMYSmj8XmHQ8HhdZNwo1ASkBGCQCYDAEFN8wYcjYskj9OSQoEXkOn0QmWDrkMAUU3zBhyNiySP05JCgReQ6fRCZYOuQYMAtA+j7ir4H1KYIxAe49jhZr/Gg7pDUKtIcYyUVJD0g9egIYHShM1y1j3BsOQTBX6mnLPp4FS4AtNsUgaM+XPKSFSxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEsHRCcOV1E99XbG3o13/fiNry5pbC+wthmwXfIJ0jDIH+SthnS7W4FLQVOjsTeKq83RwNnt9uHDQ5SwpNVn1jXTcKNQEpARgkAmAwBBRv/3RUuVdGOv0HUkHAvs+4nSiniTAFFG//dFS5V0Y6/QdSQcC+z7idKKeJGDALQKrvVhoinxo07C2nI/zakt4xUZKgab6DVI4mBXYoPQXaZM8jmEqWboPnLBUGbr9UAnqEc9yARHwlC77eXN1BCdUY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEMmvMdDf/h+u7fawdjIe6gXEeWuszCShR8ulsHMLnJYTMHVrkztOcj4cHw6haH/q909aVmL3xLlbEC2lZtmZClDcKNQEpARgkAmAwBBRoZjEcSXeh6IFBtW0A2OilJBdeYjAFFGhmMRxJd6HogUG1bQDY6KUkF15iGDALQJm5+/SkVrR4iBpGVqZZGOH+DpS+cQYqceN1+JSnDFwxJe+khYxFifMohSQ5NLlTiJQTZWYpqMKMZHT36pWWADUY" + ], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": true, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": 0, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 266, + "1": 2 + } + ], + "1/29/1": [3, 4, 6, 29, 2820, 336264194, 336264195], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/2820/0": 9, + "1/2820/1285": 2170, + "1/2820/1288": 592, + "1/2820/1291": 560, + "1/2820/1536": 1, + "1/2820/1537": 10, + "1/2820/1538": 1, + "1/2820/1539": 1000, + "1/2820/1540": 1, + "1/2820/1541": 8, + "1/2820/2049": 2530, + "1/2820/2050": 16300, + "1/2820/65532": 0, + "1/2820/65533": 3, + "1/2820/65528": [], + "1/2820/65529": [], + "1/2820/65531": [ + 0, 1285, 1288, 1291, 1536, 1537, 1538, 1539, 1540, 1541, 2049, 2050, + 65528, 65529, 65531, 65532, 65533 + ], + "1/336264194/336199680": 44, + "1/336264194/336199681": 0, + "1/336264194/336199682": 0, + "1/336264194/336199698": 70, + "1/336264194/65532": 0, + "1/336264194/65533": 1, + "1/336264194/65528": [], + "1/336264194/65529": [], + "1/336264194/65531": [ + 65528, 65529, 65531, 336199680, 336199681, 336199682, 336199698, 65532, + 65533 + ], + "1/336264195/336199680": 0, + "1/336264195/65532": 0, + "1/336264195/65533": 1, + "1/336264195/65528": [], + "1/336264195/65529": [], + "1/336264195/65531": [65528, 65529, 65531, 336199680, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 7973f1a5147..dbbc984ab2f 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -1819,3 +1819,50 @@ 'state': 'unknown', }) # --- +# name: test_buttons[yandex_smart_socket][button.yndx_00540_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.yndx_00540_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yandex_smart_socket][button.yndx_00540_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'YNDX-00540 Identify', + }), + 'context': , + 'entity_id': 'button.yndx_00540_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 19a90503086..d7ddf636ff9 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1518,13 +1518,15 @@ 'state': 'previous', }) # --- -# name: test_selects[silabs_dishwasher][select.dishwasher_mode-entry] +# name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'options': list([ + 'off', + 'normal', ]), }), 'config_entry_id': , @@ -1533,7 +1535,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': None, - 'entity_id': 'select.dishwasher_mode', + 'entity_id': 'select.laundrywasher_number_of_rinses', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1545,37 +1547,43 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Mode', + 'original_name': 'Number of rinses', 'platform': 'matter', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'mode', - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-MatterDishwasherMode-89-1', + 'translation_key': 'laundry_washer_number_of_rinses', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterLaundryWasherNumberOfRinses-83-2', 'unit_of_measurement': None, }) # --- -# name: test_selects[silabs_dishwasher][select.dishwasher_mode-state] +# name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher Mode', + 'friendly_name': 'LaundryWasher Number of rinses', 'options': list([ + 'off', + 'normal', ]), }), 'context': , - 'entity_id': 'select.dishwasher_mode', + 'entity_id': 'select.laundrywasher_number_of_rinses', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- -# name: test_selects[silabs_laundrywasher][select.laundrywasher_mode-entry] +# name: test_selects[silabs_laundrywasher][select.laundrywasher_spin_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'options': list([ + 'Off', + 'Low', + 'Medium', + 'High', ]), }), 'config_entry_id': , @@ -1584,7 +1592,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': None, - 'entity_id': 'select.laundrywasher_mode', + 'entity_id': 'select.laundrywasher_spin_speed', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1596,28 +1604,32 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Mode', + 'original_name': 'Spin speed', 'platform': 'matter', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'mode', - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterLaundryWasherMode-81-1', + 'translation_key': 'laundry_washer_spin_speed', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-LaundryWasherControlsSpinSpeed-83-1', 'unit_of_measurement': None, }) # --- -# name: test_selects[silabs_laundrywasher][select.laundrywasher_mode-state] +# name: test_selects[silabs_laundrywasher][select.laundrywasher_spin_speed-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'LaundryWasher Mode', + 'friendly_name': 'LaundryWasher Spin speed', 'options': list([ + 'Off', + 'Low', + 'Medium', + 'High', ]), }), 'context': , - 'entity_id': 'select.laundrywasher_mode', + 'entity_id': 'select.laundrywasher_spin_speed', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'Off', }) # --- # name: test_selects[silabs_laundrywasher][select.laundrywasher_temperature_level-entry] @@ -1852,3 +1864,62 @@ 'state': 'Quick', }) # --- +# name: test_selects[yandex_smart_socket][select.yndx_00540_power_on_behavior_on_startup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.yndx_00540_power_on_behavior_on_startup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power-on behavior on startup', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'startup_on_off', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[yandex_smart_socket][select.yndx_00540_power_on_behavior_on_startup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'YNDX-00540 Power-on behavior on startup', + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'context': , + 'entity_id': 'select.yndx_00540_power_on_behavior_on_startup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 5e22b9a1476..541f1bc178f 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -2870,6 +2870,64 @@ 'state': '0.0', }) # --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.laundrywasher_current_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateCurrentPhase-96-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'LaundryWasher Current phase', + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_current_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pre-soak', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3354,3 +3412,231 @@ 'state': '28.3', }) # --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + 'seeking_charger', + 'charging', + 'docked', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_vacuum_operational_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_state', + 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-RvcOperationalState-97-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Vacuum Operational state', + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + 'seeking_charger', + 'charging', + 'docked', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_vacuum_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.yndx_00540_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementRmsCurrent-2820-1288', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'YNDX-00540 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.59', + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.yndx_00540_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementActivePower-2820-1291', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'YNDX-00540 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.yndx_00540_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementRmsVoltage-2820-1285', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'YNDX-00540 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '217.0', + }) +# --- diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 612e81580a5..8277ee28838 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -421,3 +421,50 @@ 'state': 'on', }) # --- +# name: test_switches[yandex_smart_socket][switch.yndx_00540-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.yndx_00540', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterPlug-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[yandex_smart_socket][switch.yndx_00540-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'YNDX-00540', + }), + 'context': , + 'entity_id': 'switch.yndx_00540', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index 7bcfd381d6c..bb03b296fc6 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -11,7 +11,7 @@ from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ATTR_CODE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import ( set_node_attribute, diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 3643aa83fca..2403b4b1623 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion @@ -144,3 +145,56 @@ async def test_list_select_entities( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("select.laundrywasher_temperature_level") assert state.state == "unknown" + + # SpinSpeedCurrent + matter_client.write_attribute.reset_mock() + state = hass.states.get("select.laundrywasher_spin_speed") + assert state + assert state.state == "Off" + assert state.attributes["options"] == ["Off", "Low", "Medium", "High"] + set_node_attribute(matter_node, 1, 83, 1, 3) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_spin_speed") + assert state.state == "High" + # test select option + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.laundrywasher_spin_speed", + "option": "High", + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent, + ), + value=3, + ) + # test that an invalid value (e.g. 253) leads to an unknown state + set_node_attribute(matter_node, 1, 83, 1, 253) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_spin_speed") + assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_map_select_entities( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test MatterMapSelectEntity entities are discovered and working from a laundrywasher fixture.""" + # NumberOfRinses + state = hass.states.get("select.laundrywasher_number_of_rinses") + assert state + assert state.state == "off" + assert state.attributes["options"] == ["off", "normal"] + set_node_attribute(matter_node, 1, 83, 2, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_number_of_rinses") + assert state.state == "normal" diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index bd3e146264a..251aab73e3b 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -332,7 +332,7 @@ async def test_operational_state_sensor( matter_client: MagicMock, matter_node: MatterNode, ) -> None: - """Test dishwasher sensor.""" + """Test Operational State sensor, using a dishwasher fixture.""" # OperationalState Cluster / OperationalState attribute (1/96/4) state = hass.states.get("sensor.dishwasher_operational_state") assert state @@ -351,3 +351,51 @@ async def test_operational_state_sensor( state = hass.states.get("sensor.dishwasher_operational_state") assert state assert state.state == "extra_state" + + +@pytest.mark.parametrize("node_fixture", ["yandex_smart_socket"]) +async def test_draft_electrical_measurement_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Draft Electrical Measurement cluster sensors, using Yandex Smart Socket fixture.""" + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "70.0" + + # AcPowerDivisor + set_node_attribute(matter_node, 1, 2820, 1541, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "unknown" + + # ActivePower + set_node_attribute(matter_node, 1, 2820, 1291, None) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_list_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Matter List sensor.""" + # OperationalState Cluster / CurrentPhase attribute (1/96/1) + state = hass.states.get("sensor.laundrywasher_current_phase") + assert state + assert state.state == "pre-soak" + + set_node_attribute(matter_node, 1, 96, 1, 1) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.laundrywasher_current_phase") + assert state + assert state.state == "rinse" diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 750d2861f21..680603c097d 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -2,7 +2,7 @@ import pytest -import homeassistant.components.media_player as mp +from homeassistant.components import media_player as mp from homeassistant.const import ( STATE_IDLE, STATE_OFF, diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 4bb27b73f24..ae3a84e66a0 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/melissa/test_climate.py b/tests/components/melissa/test_climate.py index ceb14faf8fb..b305d629a91 100644 --- a/tests/components/melissa/test_climate.py +++ b/tests/components/melissa/test_climate.py @@ -10,7 +10,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration diff --git a/tests/components/melnor/test_sensor.py b/tests/components/melnor/test_sensor.py index a2ba23d9e61..23902a4b780 100644 --- a/tests/components/melnor/test_sensor.py +++ b/tests/components/melnor/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( mock_config_entry, diff --git a/tests/components/melnor/test_time.py b/tests/components/melnor/test_time.py index 50b51d31ff8..f8a3adcf3d0 100644 --- a/tests/components/melnor/test_time.py +++ b/tests/components/melnor/test_time.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import time, timedelta from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( mock_config_entry, diff --git a/tests/components/mfi/test_sensor.py b/tests/components/mfi/test_sensor.py index 37512ca78f8..8c21fa9cb36 100644 --- a/tests/components/mfi/test_sensor.py +++ b/tests/components/mfi/test_sensor.py @@ -7,8 +7,8 @@ from mficlient.client import FailedToLogin import pytest import requests -import homeassistant.components.mfi.sensor as mfi -import homeassistant.components.sensor as sensor_component +from homeassistant.components import sensor as sensor_component +from homeassistant.components.mfi import sensor as mfi from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant diff --git a/tests/components/mfi/test_switch.py b/tests/components/mfi/test_switch.py index 03b5d5f2c0a..fb586073a3d 100644 --- a/tests/components/mfi/test_switch.py +++ b/tests/components/mfi/test_switch.py @@ -4,8 +4,8 @@ from unittest import mock import pytest -import homeassistant.components.mfi.switch as mfi -import homeassistant.components.switch as switch_component +from homeassistant.components import switch as switch_component +from homeassistant.components.mfi import switch as mfi from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index c875697bf2f..a7a70043d94 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import get_fixture_path diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index cdea046ceea..0a2cbf44b9e 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_restore_cache diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index e105818d193..7b76dbc3528 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -107,7 +107,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( TEST_ENTITY_NAME, diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 4e0ad0841ea..4b2c123ba75 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_NAME, ReadResult diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index 8ef58cc968d..d9a9a847b63 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -45,8 +45,8 @@ from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util from homeassistant.util.aiohttp import MockRequest -import homeassistant.util.dt as dt_util from . import ( TEST_CAMERA, diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index fae7fccbb6d..bc345c0b66f 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -31,7 +31,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( TEST_CAMERA, diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index ed7dae26663..3b97c8aa7fe 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -2,7 +2,7 @@ from ipaddress import ip_address -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" @@ -21,6 +21,8 @@ MOCK_USER_INPUT = { CONF_PORT: PORT, } +MOCK_PIN_INPUT = {CONF_PIN: 1234} + MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = ZeroconfServiceInfo( type=TVM_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py index 4de23de63c9..1fa2715595d 100644 --- a/tests/components/motionmount/test_config_flow.py +++ b/tests/components/motionmount/test_config_flow.py @@ -1,20 +1,23 @@ """Tests for the Vogel's MotionMount config flow.""" import dataclasses +from datetime import timedelta import socket from unittest.mock import MagicMock, PropertyMock +from freezegun.api import FrozenDateTimeFactory import motionmount import pytest from homeassistant.components.motionmount.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( HOST, + MOCK_PIN_INPUT, MOCK_USER_INPUT, MOCK_ZEROCONF_TVM_SERVICE_INFO_V1, MOCK_ZEROCONF_TVM_SERVICE_INFO_V2, @@ -24,23 +27,12 @@ from . import ( ZEROCONF_NAME, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed MAC = bytes.fromhex("c4dd57f8a55f") pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_show_user_form(hass: HomeAssistant) -> None: - """Test that the user set up form is served.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] is FlowResultType.FORM - - async def test_user_connection_error( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -117,33 +109,6 @@ async def test_user_not_connected_error( assert result["reason"] == "not_connected" -async def test_user_response_error_single_device_old_ce_old_new_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the flow creates an entry when there is a response error.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - user_input = MOCK_USER_INPUT.copy() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - - assert result["data"] - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == PORT - - assert result["result"] - - async def test_user_response_error_single_device_new_ce_old_pro( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -199,30 +164,6 @@ async def test_user_response_error_single_device_new_ce_new_pro( assert result["result"].unique_id == ZEROCONF_MAC -async def test_user_response_error_multi_device_old_ce_old_new_pro( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the flow is aborted when there are multiple devices.""" - mock_config_entry.add_to_hass(hass) - - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - user_input = MOCK_USER_INPUT.copy() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_user_response_error_multi_device_new_ce_new_pro( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -246,6 +187,53 @@ async def test_user_response_error_multi_device_new_ce_new_pro( assert result["reason"] == "already_configured" +async def test_user_response_authentication_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + async def test_zeroconf_connection_error( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -322,48 +310,6 @@ async def test_zeroconf_not_connected_error( assert result["reason"] == "not_connected" -async def test_show_zeroconf_form_old_ce_old_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the zeroconf confirmation form is served.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=discovery_info, - ) - - assert result["step_id"] == "zeroconf_confirm" - assert result["type"] is FlowResultType.FORM - assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} - - -async def test_show_zeroconf_form_old_ce_new_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the zeroconf confirmation form is served.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=discovery_info, - ) - - assert result["step_id"] == "zeroconf_confirm" - assert result["type"] is FlowResultType.FORM - assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} - - async def test_show_zeroconf_form_new_ce_old_pro( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -384,6 +330,21 @@ async def test_show_zeroconf_form_new_ce_old_pro( assert result["type"] is FlowResultType.FORM assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id is None + async def test_show_zeroconf_form_new_ce_new_pro( hass: HomeAssistant, @@ -403,6 +364,21 @@ async def test_show_zeroconf_form_new_ce_new_pro( assert result["type"] is FlowResultType.FORM assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + async def test_zeroconf_device_exists_abort( hass: HomeAssistant, @@ -423,6 +399,346 @@ async def test_zeroconf_device_exists_abort( assert result["reason"] == "already_configured" +async def test_zeroconf_authentication_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_incorrect_then_correct_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + assert result["errors"] + assert result["errors"][CONF_PIN] == CONF_PIN + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_first_incorrect_pin_to_backoff( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + side_effect=[True, 1] + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert mock_motionmount_config_flow.authenticate.called + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_multiple_incorrect_pins( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_show_backoff_when_still_running( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + # This situation happens when the user cancels the progress dialog and tries to + # configure the MotionMount again + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=None, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_correct_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + async def test_full_user_flow_implementation( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -459,7 +775,7 @@ async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, ) -> None: - """Test the full manual user flow from start to finish.""" + """Test the full zeroconf flow from start to finish.""" type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) @@ -487,3 +803,37 @@ async def test_full_zeroconf_flow_implementation( assert result["result"] assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_full_reauth_flow_implementation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test reauthentication.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index d27163c3423..34be237fb72 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( help_custom_config, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 4e0873c6e1b..d05c340dac2 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -13,6 +13,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import mqtt from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.models import ( @@ -30,7 +31,6 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er, template diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 7f418864872..6b3bbd6334c 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( help_custom_config, diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py index b6c1940b149..cbf02299b09 100644 --- a/tests/components/mqtt_eventstream/test_init.py +++ b/tests/components/mqtt_eventstream/test_init.py @@ -5,12 +5,12 @@ from unittest.mock import ANY, patch import pytest -import homeassistant.components.mqtt_eventstream as eventstream +from homeassistant.components import mqtt_eventstream as eventstream from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( async_fire_mqtt_message, diff --git a/tests/components/mqtt_statestream/test_init.py b/tests/components/mqtt_statestream/test_init.py index 9798477945c..63c3ea14e44 100644 --- a/tests/components/mqtt_statestream/test_init.py +++ b/tests/components/mqtt_statestream/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import ANY, call import pytest -import homeassistant.components.mqtt_statestream as statestream +from homeassistant.components import mqtt_statestream as statestream from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.setup import async_setup_component diff --git a/tests/components/myuplink/fixtures/device-alfred.json b/tests/components/myuplink/fixtures/device-alfred.json new file mode 100644 index 00000000000..ca6f91459f6 --- /dev/null +++ b/tests/components/myuplink/fixtures/device-alfred.json @@ -0,0 +1,40 @@ +{ + "id": "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7A", + "desiredFwVersion": "9682R7A" + }, + "product": { + "serialNumber": "10001", + "name": "Tehowatti Air" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/device-batman.json b/tests/components/myuplink/fixtures/device-batman.json new file mode 100644 index 00000000000..f7c079be5dd --- /dev/null +++ b/tests/components/myuplink/fixtures/device-batman.json @@ -0,0 +1,40 @@ +{ + "id": "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7B", + "desiredFwVersion": "9682R7B" + }, + "product": { + "serialNumber": "10002", + "name": "F730 CU 3x400V" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/device-robin.json b/tests/components/myuplink/fixtures/device-robin.json new file mode 100644 index 00000000000..3155d6e3f70 --- /dev/null +++ b/tests/components/myuplink/fixtures/device-robin.json @@ -0,0 +1,40 @@ +{ + "id": "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7C", + "desiredFwVersion": "9682R7C" + }, + "product": { + "serialNumber": "10003", + "name": "SMO 20" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/systems-multi.json b/tests/components/myuplink/fixtures/systems-multi.json new file mode 100644 index 00000000000..a587900d23c --- /dev/null +++ b/tests/components/myuplink/fixtures/systems-multi.json @@ -0,0 +1,61 @@ +{ + "page": 1, + "itemsPerPage": 10, + "numItems": 3, + "systems": [ + { + "systemId": "123456-7890-1234", + "name": "Gotham City", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7A", + "product": { + "serialNumber": "10001", + "name": "Tehowatti Air" + } + } + ] + }, + { + "systemId": "123456-7890-1234", + "name": "Batcave", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7B", + "product": { + "serialNumber": "10002", + "name": "F730 CU 3x400V" + } + } + ] + }, + { + "systemId": "123456-7890-1234", + "name": "Duckburg", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7C", + "product": { + "serialNumber": "10003", + "name": "SM0 20" + } + } + ] + } + ] +} diff --git a/tests/components/myuplink/snapshots/test_init.ambr b/tests/components/myuplink/snapshots/test_init.ambr new file mode 100644 index 00000000000..42ed9c20669 --- /dev/null +++ b/tests/components/myuplink/snapshots/test_init.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_device_info[alfred-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Jäspi', + 'model': 'Tehowatti Air', + 'model_id': None, + 'name': 'Gotham City', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10001', + 'suggested_area': None, + 'sw_version': '9682R7A', + 'via_device_id': None, + }) +# --- +# name: test_device_info[batman-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Nibe', + 'model': 'F730', + 'model_id': None, + 'name': 'Batcave', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10002', + 'suggested_area': None, + 'sw_version': '9682R7B', + 'via_device_id': None, + }) +# --- +# name: test_device_info[robin-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Nibe', + 'model': 'SMO 20', + 'model_id': None, + 'name': 'Duckburg', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10003', + 'suggested_area': None, + 'sw_version': '9682R7C', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/myuplink/test_init.py b/tests/components/myuplink/test_init.py index fda0d3526f9..320bf202024 100644 --- a/tests/components/myuplink/test_init.py +++ b/tests/components/myuplink/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock from aiohttp import ClientConnectionError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.myuplink.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState @@ -214,3 +215,47 @@ async def test_device_remove_devices( old_device_entry.id, mock_config_entry.entry_id ) assert response["success"] + + +@pytest.mark.parametrize( + "load_systems_file", + [load_fixture("systems-multi.json", DOMAIN)], + ids=[ + "multi", + ], +) +@pytest.mark.parametrize( + ("load_device_file", "device_id"), + [ + ( + load_fixture("device-alfred.json", DOMAIN), + "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ( + load_fixture("device-batman.json", DOMAIN), + "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ( + load_fixture("device-robin.json", DOMAIN), + "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ], + ids=[ + "alfred", + "batman", + "robin", + ], +) +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + device_id: str, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index b5e3cd2b91c..92d90a18a7e 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -74,20 +74,25 @@ class FakeAuth: self.json = None self.headers = None self.captured_requests = [] + self._project_id = project_id + self._aioclient_mock = aioclient_mock + self.register_mock_requests() + def register_mock_requests(self) -> None: + """Register the mocks.""" # API makes a call to request structures to initiate pubsub feed, but the # integration does not use this. - aioclient_mock.get( - f"{API_URL}/enterprises/{project_id}/structures", + self._aioclient_mock.get( + f"{API_URL}/enterprises/{self._project_id}/structures", side_effect=self.request_structures, ) - aioclient_mock.get( - f"{API_URL}/enterprises/{project_id}/devices", + self._aioclient_mock.get( + f"{API_URL}/enterprises/{self._project_id}/devices", side_effect=self.request_devices, ) - aioclient_mock.post(DEVICE_URL_MATCH, side_effect=self.request) - aioclient_mock.get(TEST_IMAGE_URL, side_effect=self.request) - aioclient_mock.get(TEST_CLIP_URL, side_effect=self.request) + self._aioclient_mock.post(DEVICE_URL_MATCH, side_effect=self.request) + self._aioclient_mock.get(TEST_IMAGE_URL, side_effect=self.request) + self._aioclient_mock.get(TEST_CLIP_URL, side_effect=self.request) async def request_structures( self, method: str, url: str, data: dict[str, Any] diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index f08eeb82a1d..0e6ec290841 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -34,7 +34,7 @@ from tests.typing import ClientSessionGenerator WEB_REDIRECT_URL = "https://example.com/auth/external/callback" APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" -RAND_SUBSCRIBER_SUFFIX = "ABCDEF" +RAND_SUFFIX = "ABCDEF" FAKE_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="fake_hostname" @@ -52,7 +52,7 @@ def mock_rand_topic_name_fixture() -> None: """Set the topic name random string to a constant.""" with patch( "homeassistant.components.nest.config_flow.get_random_string", - return_value=RAND_SUBSCRIBER_SUFFIX, + return_value=RAND_SUFFIX, ): yield @@ -173,6 +173,7 @@ class OAuthFixture: selected_topic: str, selected_subscription: str = "create_new_subscription", user_input: dict | None = None, + existing_errors: dict | None = None, ) -> ConfigEntry: """Fixture to walk through the Pub/Sub topic and subscription steps. @@ -193,6 +194,12 @@ class OAuthFixture: }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + # ACK the topic selection. User is instructed to do some manual + result = await self.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert not result.get("errors") @@ -267,6 +274,12 @@ def mock_cloud_project_id() -> str: return CLOUD_PROJECT_ID +@pytest.fixture(name="create_topic_status") +def mock_create_topic_status() -> str: + """Fixture to configure the return code when creating the topic.""" + return HTTPStatus.OK + + @pytest.fixture(name="create_subscription_status") def mock_create_subscription_status() -> str: """Fixture to configure the return code when creating the subscription.""" @@ -285,6 +298,64 @@ def mock_list_subscriptions_status() -> str: return HTTPStatus.OK +def setup_mock_list_subscriptions_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + subscriptions: list[tuple[str, str]], + list_subscriptions_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for listing Pub/Sub subscriptions.""" + aioclient_mock.get( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions", + json={ + "subscriptions": [ + { + "name": subscription_name, + "topic": topic, + "pushConfig": {}, + "ackDeadlineSeconds": 10, + "messageRetentionDuration": "604800s", + "expirationPolicy": {"ttl": "2678400s"}, + "state": "ACTIVE", + } + for (subscription_name, topic) in subscriptions or () + ] + }, + status=list_subscriptions_status, + ) + + +def setup_mock_create_topic_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + create_topic_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for creating a Pub/Sub topic.""" + aioclient_mock.put( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/topics/home-assistant-{RAND_SUFFIX}", + json={}, + status=create_topic_status, + ) + aioclient_mock.post( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/topics/home-assistant-{RAND_SUFFIX}:setIamPolicy", + json={}, + status=create_topic_status, + ) + + +def setup_mock_create_subscription_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + create_subscription_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for creating a Pub/Sub subscription.""" + aioclient_mock.put( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions/home-assistant-{RAND_SUFFIX}", + json={}, + status=create_subscription_status, + ) + + @pytest.fixture(autouse=True) def mock_pubsub_api_responses( aioclient_mock: AiohttpClientMocker, @@ -293,6 +364,7 @@ def mock_pubsub_api_responses( subscriptions: list[tuple[str, str]], device_access_project_id: str, cloud_project_id: str, + create_topic_status: HTTPStatus, create_subscription_status: HTTPStatus, list_topics_status: HTTPStatus, list_subscriptions_status: HTTPStatus, @@ -320,28 +392,14 @@ def mock_pubsub_api_responses( ) # We check for a topic created by the SDM Device Access Console (but note we don't have permission to read it) # or the user has created one themselves in the Google Cloud Project. - aioclient_mock.get( - f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions", - json={ - "subscriptions": [ - { - "name": subscription_name, - "topic": topic, - "pushConfig": {}, - "ackDeadlineSeconds": 10, - "messageRetentionDuration": "604800s", - "expirationPolicy": {"ttl": "2678400s"}, - "state": "ACTIVE", - } - for (subscription_name, topic) in subscriptions or () - ] - }, - status=list_subscriptions_status, + setup_mock_list_subscriptions_responses( + aioclient_mock, cloud_project_id, subscriptions, list_subscriptions_status ) - aioclient_mock.put( - f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", - json={}, - status=create_subscription_status, + setup_mock_create_topic_responses( + aioclient_mock, cloud_project_id, create_topic_status + ) + setup_mock_create_subscription_responses( + aioclient_mock, cloud_project_id, create_subscription_status ) @@ -371,7 +429,7 @@ async def test_app_credentials( "auth_implementation": "imported-cred", "cloud_project_id": CLOUD_PROJECT_ID, "project_id": PROJECT_ID, - "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", "token": { "refresh_token": "mock-refresh-token", @@ -520,6 +578,11 @@ async def test_config_flow_pubsub_configuration_error( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -565,6 +628,11 @@ async def test_config_flow_pubsub_subscriber_error( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -691,37 +759,6 @@ async def test_reauth_multiple_config_entries( assert entry.data.get("extra_data") -@pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) -async def test_pubsub_subscription_strip_whitespace( - hass: HomeAssistant, - oauth: OAuthFixture, -) -> None: - """Check that project id has whitespace stripped on entry.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await oauth.async_app_creds_flow( - result, cloud_project_id=" " + CLOUD_PROJECT_ID + " " - ) - oauth.async_mock_refresh() - result = await oauth.async_configure(result, {"code": "1234"}) - entry = await oauth.async_complete_pubsub_flow( - result, selected_topic="projects/sdm-prod/topics/enterprise-some-project-id" - ) - assert entry.title == "Import from configuration.yaml" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == PROJECT_ID - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert "subscription_name" in entry.data - assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID - - @pytest.mark.parametrize( ("sdm_managed_topic", "create_subscription_status"), [(True, HTTPStatus.UNAUTHORIZED)], @@ -751,6 +788,11 @@ async def test_pubsub_subscription_auth_failure( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -833,7 +875,7 @@ async def test_config_entry_title_from_home( assert entry.data.get("cloud_project_id") == CLOUD_PROJECT_ID assert ( entry.data.get("subscription_name") - == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}" + == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}" ) assert ( entry.data.get("topic_name") @@ -905,7 +947,7 @@ async def test_title_failure_fallback( assert entry.data.get("cloud_project_id") == CLOUD_PROJECT_ID assert ( entry.data.get("subscription_name") - == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}" + == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}" ) assert ( entry.data.get("topic_name") @@ -997,7 +1039,7 @@ async def test_dhcp_discovery_with_creds( "auth_implementation": "imported-cred", "cloud_project_id": CLOUD_PROJECT_ID, "project_id": PROJECT_ID, - "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", "token": { "refresh_token": "mock-refresh-token", @@ -1092,7 +1134,7 @@ async def test_no_eligible_topics( hass: HomeAssistant, oauth: OAuthFixture, ) -> None: - """Test the case where there are no eligible pub/sub topics.""" + """Test the case where there are no eligible pub/sub topics and the topic is created.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1101,8 +1143,36 @@ async def test_no_eligible_topics( result = await oauth.async_configure(result, None) assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub" - assert result.get("errors") == {"base": "no_pubsub_topics"} + assert result.get("step_id") == "pubsub_topic" + assert not result.get("errors") + # Option shown to create a new topic + assert result.get("data_schema")({}) == { + "topic_name": "create_new_topic", + } + + entry = await oauth.async_complete_pubsub_flow( + result, + selected_topic="create_new_topic", + selected_subscription="create_new_subscription", + ) + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/home-assistant-{RAND_SUFFIX}", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } @pytest.mark.parametrize( @@ -1122,11 +1192,90 @@ async def test_list_topics_failure( await oauth.async_app_creds_flow(result) oauth.async_mock_refresh() + result = await oauth.async_configure(result, None) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "pubsub_api_error" + + +@pytest.mark.parametrize( + ("create_topic_status"), + [(HTTPStatus.INTERNAL_SERVER_ERROR)], +) +async def test_create_topic_failed( + hass: HomeAssistant, + oauth: OAuthFixture, + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + subscriptions: list[tuple[str, str]], + auth: FakeAuth, +) -> None: + """Test the case where there are no eligible pub/sub topics and the topic is created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() + result = await oauth.async_configure(result, None) assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub" + assert result.get("step_id") == "pubsub_topic" + assert not result.get("errors") + # Option shown to create a new topic + assert result.get("data_schema")({}) == { + "topic_name": "create_new_topic", + } + + result = await oauth.async_configure(result, {"topic_name": "create_new_topic"}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic" assert result.get("errors") == {"base": "pubsub_api_error"} + # Re-register mock requests needed for the rest of the test. The topic + # request will now succeed. + aioclient_mock.clear_requests() + setup_mock_create_topic_responses(aioclient_mock, cloud_project_id) + # Fix up other mock responses cleared above + auth.register_mock_requests() + setup_mock_list_subscriptions_responses( + aioclient_mock, + cloud_project_id, + subscriptions, + ) + setup_mock_create_subscription_responses(aioclient_mock, cloud_project_id) + + result = await oauth.async_configure(result, {"topic_name": "create_new_topic"}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_subscription" + assert not result.get("errors") + + # Create a subscription for the topic and end the flow + entry = await oauth.async_finish_setup( + result, + {"subscription_name": "create_new_subscription"}, + ) + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/home-assistant-{RAND_SUFFIX}", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } + @pytest.mark.parametrize( ("sdm_managed_topic", "list_subscriptions_status"), @@ -1158,5 +1307,10 @@ async def test_list_subscriptions_failure( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("errors") == {"base": "pubsub_api_error"} diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 051f7bb87e4..d009e1185da 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DEVICE_ID, diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 730cb0cb117..9110f8c724f 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -11,7 +11,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.webhook import async_handle_webhook from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util.aiohttp import MockRequest from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/netatmo/test_button.py b/tests/components/netatmo/test_button.py index 681e42af051..bffecf7d83a 100644 --- a/tests/components/netatmo/test_button.py +++ b/tests/components/netatmo/test_button.py @@ -8,7 +8,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 43904ed8f71..32f20544043 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -19,7 +19,7 @@ from homeassistant.components.netatmo.const import ( from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .common import ( diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index dc0312f7acd..18c811fd76b 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .common import selected_platforms, simulate_webhook, snapshot_platform_entities diff --git a/tests/components/netatmo/test_cover.py b/tests/components/netatmo/test_cover.py index 509c1de736e..9368a564afb 100644 --- a/tests/components/netatmo/test_cover.py +++ b/tests/components/netatmo/test_cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netatmo/test_fan.py b/tests/components/netatmo/test_fan.py index 989ea1ac364..3dbc8b3a6f5 100644 --- a/tests/components/netatmo/test_fan.py +++ b/tests/components/netatmo/test_fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index c90d67e7630..0932395b8ec 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -12,7 +12,7 @@ from homeassistant.components.light import ( from homeassistant.components.netatmo import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import ( FAKE_WEBHOOK_ACTIVATION, diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index 274113405f6..458115f8f5c 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -17,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, simulate_webhook, snapshot_platform_entities diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index dd82fad3d08..837f6201b1e 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netgear_lte/test_init.py b/tests/components/netgear_lte/test_init.py index 1bd3dff1eff..e853109e33e 100644 --- a/tests/components/netgear_lte/test_init.py +++ b/tests/components/netgear_lte/test_init.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import CONF_DATA diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py index 6e55f89e54a..ff4c5bdf72a 100644 --- a/tests/components/nmbs/test_config_flow.py +++ b/tests/components/nmbs/test_config_flow.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock import pytest from homeassistant import config_entries +from homeassistant.components.nmbs.config_flow import CONF_EXCLUDE_VIAS from homeassistant.components.nmbs.const import ( CONF_STATION_FROM, CONF_STATION_LIVE, @@ -120,6 +121,23 @@ async def test_abort_if_exists( assert result["reason"] == "already_configured" +async def test_dont_abort_if_exists_when_vias_differs( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test aborting the flow if the entry already exists.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + CONF_EXCLUDE_VIAS: True, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + async def test_unavailable_api( hass: HomeAssistant, mock_nmbs_client: AsyncMock ) -> None: @@ -158,7 +176,10 @@ async def test_import( CONF_STATION_LIVE: "BE.NMBS.008813003", CONF_STATION_TO: "BE.NMBS.008814001", } - assert result["result"].unique_id == "BE.NMBS.008812005_BE.NMBS.008814001" + assert ( + result["result"].unique_id + == f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) async def test_step_import_abort_if_already_setup( diff --git a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py index ad987325b97..96d5e815ff0 100644 --- a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py +++ b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/nuheat/test_climate.py b/tests/components/nuheat/test_climate.py index bc00df126e5..5e3ba384b2d 100644 --- a/tests/components/nuheat/test_climate.py +++ b/tests/components/nuheat/test_climate.py @@ -6,7 +6,7 @@ from unittest.mock import patch from homeassistant.components.nuheat.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( MOCK_CONFIG_ENTRY, diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr new file mode 100644 index 00000000000..b57c6cf96dd --- /dev/null +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_onboarding_backup_info + dict({ + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': True, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + dict({ + 'addons': list([ + ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'size': 0, + }), + }), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'with_automatic_settings': None, + }), + ]), + 'last_non_idle_event': None, + 'state': 'idle', + }) +# --- diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 35f6b7d739c..98f6426609e 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -3,13 +3,15 @@ import asyncio from collections.abc import AsyncGenerator from http import HTTPStatus +from io import StringIO import os from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import pytest +from syrupy import SnapshotAssertion -from homeassistant.components import onboarding +from homeassistant.components import backup, onboarding from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar @@ -649,12 +651,28 @@ async def test_onboarding_installation_type( assert resp_content["installation_type"] == "Home Assistant Core" -async def test_onboarding_installation_type_after_done( +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "installation_type", {}), + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_view_after_done( hass: HomeAssistant, hass_storage: dict[str, Any], hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], ) -> None: - """Test raising for installation type after onboarding.""" + """Test raising after onboarding.""" mock_storage(hass_storage, {"done": [const.STEP_USER]}) assert await async_setup_component(hass, "onboarding", {}) @@ -662,7 +680,7 @@ async def test_onboarding_installation_type_after_done( client = await hass_client() - resp = await client.get("/api/onboarding/installation_type") + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) assert resp.status == 401 @@ -726,3 +744,286 @@ async def test_complete_onboarding( listener_3 = Mock() onboarding.async_add_listener(hass, listener_3) listener_3.assert_called_once_with() + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_backup_view_without_backup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test interacting with backup wievs when backup integration is missing.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 500 + assert await resp.json() == {"error": "backup_disabled"} + + +async def test_onboarding_backup_info( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + backups = { + "abc123": backup.ManagerBackup( + addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={ + "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="abc123", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + failed_agent_ids=[], + with_automatic_settings=True, + ), + "def456": backup.ManagerBackup( + addons=[], + agents={ + "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="def456", + date="1980-01-01T00:00:00.000Z", + database_included=False, + extra_metadata={ + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test 2", + failed_agent_ids=[], + with_automatic_settings=None, + ), + } + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ): + resp = await client.get("/api/onboarding/backup/info") + + assert resp.status == 200 + assert await resp.json() == snapshot + + +@pytest.mark.parametrize( + ("params", "expected_kwargs"), + [ + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + { + "agent_id": "backup.local", + "password": None, + "restore_addons": None, + "restore_database": True, + "restore_folders": None, + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": ["media"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": [backup.Folder.MEDIA], + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": ["media", "share"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], + "restore_homeassistant": True, + }, + ), + ], +) +async def test_onboarding_backup_restore( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + expected_kwargs: dict[str, Any], +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + assert resp.status == 200 + mock_restore.assert_called_once_with("abc123", **expected_kwargs) + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), + [ + # Missing agent_id + ( + {"backup_id": "abc123"}, + None, + 400, + "Message format incorrect: required key not provided @ data['agent_id']", + 0, + ), + # Missing backup_id + ( + {"agent_id": "backup.local"}, + None, + 400, + "Message format incorrect: required key not provided @ data['backup_id']", + 0, + ), + # Invalid restore_database + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_database": "yes_please", + }, + None, + 400, + "Message format incorrect: expected bool for dictionary value @ data['restore_database']", + 0, + ), + # Invalid folder + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_folders": ["invalid"], + }, + None, + 400, + "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]", + 0, + ), + # Wrong password + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + backup.IncorrectPasswordError, + 400, + "incorrect_password", + 1, + ), + ], +) +async def test_onboarding_backup_restore_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_message: str, + restore_calls: int, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert await resp.json() == {"message": expected_message} + assert len(mock_restore.mock_calls) == restore_calls + + +async def test_onboarding_backup_upload( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value="abc123", + ) as mock_receive: + resp = await client.post( + "/api/onboarding/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + assert resp.status == 201 + assert await resp.json() == {"backup_id": "abc123"} + mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) diff --git a/tests/components/onedrive/__init__.py b/tests/components/onedrive/__init__.py new file mode 100644 index 00000000000..0bafe37775b --- /dev/null +++ b/tests/components/onedrive/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the OneDrive integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the OneDrive integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py new file mode 100644 index 00000000000..65142217017 --- /dev/null +++ b/tests/components/onedrive/conftest.py @@ -0,0 +1,178 @@ +"""Fixtures for OneDrive tests.""" + +from collections.abc import AsyncIterator, Generator +from html import escape +from json import dumps +import time +from unittest.mock import AsyncMock, MagicMock, patch + +from httpx import Response +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.drive_item_collection_response import ( + DriveItemCollectionResponse, +) +from msgraph.generated.models.upload_session import UploadSession +from msgraph_core.models import LargeFileUploadSession +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.onedrive.const import DOMAIN, OAUTH_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return OAUTH_SCOPES + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + unique_id="mock_drive_id", + ) + + +@pytest.fixture +def mock_adapter() -> Generator[MagicMock]: + """Return a mocked GraphAdapter.""" + with ( + patch( + "homeassistant.components.onedrive.config_flow.GraphRequestAdapter", + autospec=True, + ) as mock_adapter, + patch( + "homeassistant.components.onedrive.backup.GraphRequestAdapter", + new=mock_adapter, + ), + ): + adapter = mock_adapter.return_value + adapter.get_http_response_message.return_value = Response( + status_code=200, + json={ + "parentReference": {"driveId": "mock_drive_id"}, + "createdBy": {"user": {"displayName": "John Doe"}}, + }, + ) + yield adapter + adapter.send_async.return_value = LargeFileUploadSession( + next_expected_ranges=["2-"] + ) + + +@pytest.fixture(autouse=True) +def mock_graph_client(mock_adapter: MagicMock) -> Generator[MagicMock]: + """Return a mocked GraphServiceClient.""" + with ( + patch( + "homeassistant.components.onedrive.config_flow.GraphServiceClient", + autospec=True, + ) as graph_client, + patch( + "homeassistant.components.onedrive.GraphServiceClient", + new=graph_client, + ), + ): + client = graph_client.return_value + + client.request_adapter = mock_adapter + + drives = client.drives.by_drive_id.return_value + drives.special.by_drive_item_id.return_value.get = AsyncMock( + return_value=DriveItem(id="approot") + ) + + drive_items = drives.items.by_drive_item_id.return_value + drive_items.get = AsyncMock(return_value=DriveItem(id="folder_id")) + drive_items.children.post = AsyncMock(return_value=DriveItem(id="folder_id")) + drive_items.children.get = AsyncMock( + return_value=DriveItemCollectionResponse( + value=[ + DriveItem(description=escape(dumps(BACKUP_METADATA))), + DriveItem(), + ] + ) + ) + drive_items.delete = AsyncMock(return_value=None) + drive_items.create_upload_session.post = AsyncMock( + return_value=UploadSession(upload_url="https://test.tld") + ) + drive_items.patch = AsyncMock(return_value=None) + + async def generate_bytes() -> AsyncIterator[bytes]: + """Asynchronous generator that yields bytes.""" + yield b"backup data" + + drive_items.content.get = AsyncMock( + return_value=Response(status_code=200, content=generate_bytes()) + ) + + yield client + + +@pytest.fixture +def mock_drive_items(mock_graph_client: MagicMock) -> MagicMock: + """Return a mocked DriveItems.""" + return mock_graph_client.drives.by_drive_id.return_value.items.by_drive_item_id.return_value + + +@pytest.fixture +def mock_get_special_folder(mock_graph_client: MagicMock) -> MagicMock: + """Mock the get special folder method.""" + return mock_graph_client.drives.by_drive_id.return_value.special.by_drive_item_id.return_value.get + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.onedrive.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_instance_id() -> Generator[AsyncMock]: + """Mock the instance ID.""" + with patch( + "homeassistant.components.onedrive.async_get_instance_id", + return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + ): + yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py new file mode 100644 index 00000000000..c187feef30a --- /dev/null +++ b/tests/components/onedrive/const.py @@ -0,0 +1,19 @@ +"""Consts for OneDrive tests.""" + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +BACKUP_METADATA = { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, +} diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py new file mode 100644 index 00000000000..3492202d3fe --- /dev/null +++ b/tests/components/onedrive/test_backup.py @@ -0,0 +1,388 @@ +"""Test the backups for OneDrive.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from html import escape +from io import StringIO +from json import dumps +from unittest.mock import Mock, patch + +from kiota_abstractions.api_error import APIError +from msgraph.generated.models.drive_item import DriveItem +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .const import BACKUP_METADATA + +from tests.common import AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up onedrive integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.unique_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "agents": { + "onedrive.mock_drive_id": {"protected": False, "size": 34519040} + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + mock_drive_items.get = AsyncMock( + return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) + ) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.unique_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_drive_items.delete.assert_called_once() + + +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, +) -> None: + """Test agent delete backup.""" + mock_drive_items.delete = AsyncMock(side_effect=APIError(response_status_code=404)) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_drive_items.delete.assert_called_once() + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_drive_items.create_upload_session.post.assert_called_once() + mock_drive_items.patch.assert_called_once() + assert mock_adapter.send_async.call_count == 2 + assert mock_adapter.method_calls[0].args[0].content == b"tes" + assert mock_adapter.method_calls[0].args[0].headers.get("Content-Range") == { + "bytes 0-2/34519040" + } + assert mock_adapter.method_calls[1].args[0].content == b"t" + assert mock_adapter.method_calls[1].args[0].headers.get("Content-Range") == { + "bytes 3-3/34519040" + } + + +async def test_broken_upload_session( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test broken upload session.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_drive_items.create_upload_session.post = AsyncMock(return_value=None) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert "Failed to start backup upload" in caplog.text + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + mock_drive_items.get = AsyncMock( + return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) + ) + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.unique_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + mock_drive_items.content.get.assert_called_once() + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + APIError(response_status_code=500), + "Backup operation failed", + ), + (TimeoutError(), "Backup operation timed out"), + ], +) +async def test_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test error during delete.""" + mock_drive_items.delete = AsyncMock(side_effect=side_effect) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {f"{DOMAIN}.{mock_config_entry.unique_id}": error} + } + + +@pytest.mark.parametrize( + "problem", + [ + AsyncMock(return_value=None), + AsyncMock(side_effect=APIError(response_status_code=404)), + ], +) +async def test_agents_backup_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + problem: AsyncMock, +) -> None: + """Test backup not found.""" + + mock_drive_items.get = problem + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] is None + + +async def test_agents_backup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup not found.""" + + mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=500)) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == { + f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" + } + + +async def test_reauth_on_403( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we re-authenticate on 403.""" + + mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=403)) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == { + f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" + } + + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert "context" in flow + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py new file mode 100644 index 00000000000..8be6aadfd0f --- /dev/null +++ b/tests/components/onedrive/test_config_flow.py @@ -0,0 +1,197 @@ +"""Test the OneDrive config flow.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock + +from httpx import Response +from kiota_abstractions.api_error import APIError +import pytest + +from homeassistant import config_entries +from homeassistant.components.onedrive.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import setup_integration +from .const import CLIENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def _do_get_token( + hass: HomeAssistant, + result: ConfigFlowResult, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + scope = "Files.ReadWrite.AppFolder+offline_access+openid" + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, +) -> None: + """Check full flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["title"] == "John Doe's OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (Exception, "unknown"), + (APIError, "connection_error"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_adapter: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test errors during flow.""" + + mock_adapter.get_http_response_message.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test already configured account.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works.""" + + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow_id_changed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test that the reauth flow fails on a different drive id.""" + mock_adapter.get_http_response_message.return_value = Response( + status_code=200, + json={ + "parentReference": {"driveId": "other_drive_id"}, + }, + ) + + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_drive" diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py new file mode 100644 index 00000000000..bc5c22c3ce6 --- /dev/null +++ b/tests/components/onedrive/test_init.py @@ -0,0 +1,112 @@ +"""Test the OneDrive setup.""" + +from unittest.mock import MagicMock + +from kiota_abstractions.api_error import APIError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect", "state"), + [ + (APIError(response_status_code=403), ConfigEntryState.SETUP_ERROR), + (APIError(response_status_code=500), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_approot_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_special_folder: MagicMock, + side_effect: Exception, + state: ConfigEntryState, +) -> None: + """Test errors during approot retrieval.""" + mock_get_special_folder.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is state + + +async def test_faulty_approot( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_special_folder: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty approot retrieval.""" + mock_get_special_folder.return_value = None + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get approot folder" in caplog.text + + +async def test_faulty_integration_folder( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty approot retrieval.""" + mock_drive_items.get.return_value = None + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_9f86d081 folder" in caplog.text + + +async def test_500_error_during_backup_folder_get( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error during backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=500) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_9f86d081 folder" in caplog.text + + +async def test_error_during_backup_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error during backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=404) + mock_drive_items.children.post.side_effect = APIError() + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to create backups_9f86d081 folder" in caplog.text + + +async def test_successful_backup_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, +) -> None: + """Test successful backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=404) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 4c05442eadc..370bcc871c6 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -65,6 +65,19 @@ MOCK_OWPROXY_DEVICES = { }, }, }, + "20.111111111111": { + ATTR_INJECT_READS: { + "/type": [b"DS2450"], + "/volt.A": [b" 1.1"], + "/volt.B": [b" 2.2"], + "/volt.C": [b" 3.3"], + "/volt.D": [b" 4.4"], + "/latestvolt.A": [b" 1.11"], + "/latestvolt.B": [b" 2.22"], + "/latestvolt.C": [b" 3.33"], + "/latestvolt.D": [b" 4.44"], + } + }, "22.111111111111": { ATTR_INJECT_READS: { "/type": [b"DS1822"], diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index 159f3acea42..ee5d6d99158 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -159,6 +159,38 @@ 'via_device_id': None, }) # --- +# name: test_registry[20.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '20.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2450', + 'model_id': 'DS2450', + 'name': '20.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_registry[22.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 1b8484b27a4..b963e29d160 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -260,6 +260,430 @@ 'state': '248125', }) # --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.A', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.A', + 'friendly_name': '20.111111111111 Latest voltage A', + 'raw_value': 1.11, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.11', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.B', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.B', + 'friendly_name': '20.111111111111 Latest voltage B', + 'raw_value': 2.22, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.22', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage C', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.C', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.C', + 'friendly_name': '20.111111111111 Latest voltage C', + 'raw_value': 3.33, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.33', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_d-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage D', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.D', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.D', + 'friendly_name': '20.111111111111 Latest voltage D', + 'raw_value': 4.44, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.44', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.A', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.A', + 'friendly_name': '20.111111111111 Voltage A', + 'raw_value': 1.1, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.B', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.B', + 'friendly_name': '20.111111111111 Voltage B', + 'raw_value': 2.2, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage C', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.C', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.C', + 'friendly_name': '20.111111111111 Voltage C', + 'raw_value': 3.3, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_d-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage D', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.D', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.D', + 'friendly_name': '20.111111111111 Voltage D', + 'raw_value': 4.4, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- # name: test_sensors[sensor.22_111111111111_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/persistent_notification/conftest.py b/tests/components/persistent_notification/conftest.py index 29ba5a6008a..76fdc70ea7b 100644 --- a/tests/components/persistent_notification/conftest.py +++ b/tests/components/persistent_notification/conftest.py @@ -2,7 +2,7 @@ import pytest -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 956183d8420..89559d45dc4 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -1,6 +1,6 @@ """The tests for the persistent notification component.""" -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/persistent_notification/test_trigger.py b/tests/components/persistent_notification/test_trigger.py index 16208143447..5e03fbf5f19 100644 --- a/tests/components/persistent_notification/test_trigger.py +++ b/tests/components/persistent_notification/test_trigger.py @@ -2,7 +2,7 @@ from typing import Any -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.components.persistent_notification import trigger from homeassistant.core import Context, HomeAssistant, callback diff --git a/tests/components/plex/helpers.py b/tests/components/plex/helpers.py index 434c31996e4..4dc80d3e7aa 100644 --- a/tests/components/plex/helpers.py +++ b/tests/components/plex/helpers.py @@ -7,7 +7,7 @@ from plexwebsocket import SIGNAL_CONNECTION_STATE, STATE_CONNECTED from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 490091998ff..036b2d87f3f 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DEFAULT_DATA, DEFAULT_OPTIONS, PLEX_DIRECT_URL from .helpers import trigger_plex_update, wait_for_debouncer diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index b3243d6b127..11aa68bded7 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -7,8 +7,8 @@ import pytest from homeassistant.components.plugwise.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -import homeassistant.helpers.entity_registry as er from tests.common import MockConfigEntry diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py index fa8a8a434e7..003c47ed1f4 100644 --- a/tests/components/plugwise/test_switch.py +++ b/tests/components/plugwise/test_switch.py @@ -17,7 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index cd4f1250aa4..ab5034de637 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( MOCK_GATEWAY_DIN, diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index fa2d986d12a..9b533304fbc 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import MOCK_GATEWAY_DIN, _mock_powerwall_with_fixtures diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 540e644aca4..e724a9e5cab 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -34,7 +34,7 @@ from homeassistant.components.profiler.const import DOMAIN from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index eeb181e0670..22a546e6abe 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -15,8 +15,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.util import slugify from tests.common import MockConfigEntry diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py index 20659182726..aefee4113cc 100644 --- a/tests/components/qld_bushfire/test_geo_location.py +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 563ac504057..9139e13a957 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import setup_integration diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 78a7ddaa300..2466a761364 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.auto_repairs.statistics.duplicates import from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ...common import async_wait_recording_done diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index fbb0991c960..792000c3725 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -37,7 +37,7 @@ from homeassistant.components.recorder.db_schema import ( from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask from homeassistant.const import UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import db_schema_0 diff --git a/tests/components/recorder/db_schema_0.py b/tests/components/recorder/db_schema_0.py index 12336dcc96a..12228e99211 100644 --- a/tests/components/recorder/db_schema_0.py +++ b/tests/components/recorder/db_schema_0.py @@ -23,7 +23,7 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_16.py b/tests/components/recorder/db_schema_16.py index 522bd6ea367..3455af1d019 100644 --- a/tests/components/recorder/db_schema_16.py +++ b/tests/components/recorder/db_schema_16.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_18.py b/tests/components/recorder/db_schema_18.py index 026227f68a0..9e9dc786580 100644 --- a/tests/components/recorder/db_schema_18.py +++ b/tests/components/recorder/db_schema_18.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_22.py b/tests/components/recorder/db_schema_22.py index 770d25c9cf2..766ff88ff72 100644 --- a/tests/components/recorder/db_schema_22.py +++ b/tests/components/recorder/db_schema_22.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_23.py b/tests/components/recorder/db_schema_23.py index 8cf3e16e5a8..fe36029b61f 100644 --- a/tests/components/recorder/db_schema_23.py +++ b/tests/components/recorder/db_schema_23.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 2ba62ba78f5..a77bc1fcbd5 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -49,7 +49,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_25.py b/tests/components/recorder/db_schema_25.py index 3b7c4a300c2..bd3cb23bd07 100644 --- a/tests/components/recorder/db_schema_25.py +++ b/tests/components/recorder/db_schema_25.py @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 4d7f893de25..7f34343d995 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -43,7 +43,7 @@ from homeassistant.const import ( MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 97c33334111..185dce786de 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -50,7 +50,7 @@ from homeassistant.const import ( from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSON_DUMP, json_bytes -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index 39ddb8e3148..daa7fb6977c 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -51,7 +51,7 @@ from homeassistant.const import ( from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSON_DUMP, json_bytes -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/tests/components/recorder/db_schema_42.py b/tests/components/recorder/db_schema_42.py index efeade46562..a5381d633cb 100644 --- a/tests/components/recorder/db_schema_42.py +++ b/tests/components/recorder/db_schema_42.py @@ -66,7 +66,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/tests/components/recorder/db_schema_43.py b/tests/components/recorder/db_schema_43.py index 8e77e8782ee..379e6fbd416 100644 --- a/tests/components/recorder/db_schema_43.py +++ b/tests/components/recorder/db_schema_43.py @@ -66,7 +66,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/tests/components/recorder/db_schema_9.py b/tests/components/recorder/db_schema_9.py index f9a8c2d2cad..784e326e1c3 100644 --- a/tests/components/recorder/db_schema_9.py +++ b/tests/components/recorder/db_schema_9.py @@ -25,7 +25,7 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index d9dbbf191f6..166451cc971 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -22,7 +22,7 @@ from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index bfe5c852ca6..142d2fc87f6 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 23ac6f9fb8a..1523f373ea8 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -10,15 +10,15 @@ from unittest.mock import sentinel from freezegun import freeze_time import pytest +from homeassistant import core as ha from homeassistant.components import recorder from homeassistant.components.recorder import Recorder, history from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope -import homeassistant.core as ha from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index e60a4705ac8..081394c780c 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -31,7 +31,7 @@ from homeassistant.components.recorder.db_schema import ( from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.helpers import recorder as recorder_helper -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import async_wait_recording_done, create_engine_test from .conftest import InstrumentedMigration diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 94b7518edb7..0a5f5d4da73 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -41,7 +41,7 @@ from homeassistant.components.recorder.util import ( session_scope, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.ulid import bytes_to_ulid, ulid_at_time, ulid_to_bytes from .common import ( diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index b2894883ff2..689441260c7 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -5,6 +5,7 @@ from unittest.mock import PropertyMock import pytest +from homeassistant import core as ha from homeassistant.components.recorder.const import SupportedDialect from homeassistant.components.recorder.db_schema import ( EventData, @@ -18,7 +19,6 @@ from homeassistant.components.recorder.models import ( process_timestamp_to_utc_isoformat, ) from homeassistant.const import EVENT_STATE_CHANGED -import homeassistant.core as ha from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 2baf7f2bcbc..6e192295c58 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -41,7 +41,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index dafa4da81ee..49b8836af70 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -17,7 +17,7 @@ import pytest from homeassistant.components import recorder from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.util import session_scope -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( CREATE_ENGINE_TARGET, diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 58be23bdc85..c4c1285990d 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.util import session_scope from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import Event, EventOrigin, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import async_wait_recording_done from .conftest import instrument_migration diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 94ed8da1b92..9e5172ae1f0 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -27,7 +27,7 @@ from homeassistant.components.sensor import UNIT_CONVERTERS from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .common import ( diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py index c68fe14430a..3ada2d343fe 100644 --- a/tests/components/remember_the_milk/test_init.py +++ b/tests/components/remember_the_milk/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, mock_open, patch -import homeassistant.components.remember_the_milk as rtm +from homeassistant.components import remember_the_milk as rtm from homeassistant.core import HomeAssistant from .const import JSON_STRING, PROFILE, TOKEN diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index 6c9334aeac4..b4dd513c317 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index c647faba2c1..800d090fd7b 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py index 9329edb3a00..fd113bceaa0 100644 --- a/tests/components/rflink/test_binary_sensor.py +++ b/tests/components/rflink/test_binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CoreState, HomeAssistant, State, callback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_init import mock_rflink diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index d65bf7c61d7..e5fc5cb7eb6 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -2,7 +2,11 @@ from collections.abc import Generator from copy import deepcopy -from unittest.mock import patch +import pathlib +import shutil +from typing import Any +from unittest.mock import Mock, patch +import uuid import pytest from roborock import RoborockCategory, RoomMapping @@ -69,6 +73,9 @@ def bypass_api_fixture() -> None: with ( patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), patch("homeassistant.components.roborock.RoborockMqttClientV1._send_command"), + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1._send_command" + ), patch( "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", return_value=HOME_DATA, @@ -139,6 +146,22 @@ def bypass_api_fixture() -> None: yield +@pytest.fixture(name="send_message_side_effect") +def send_message_side_effect_fixture() -> Any: + """Fixture to return a side effect for the send_message method.""" + return None + + +@pytest.fixture(name="mock_send_message") +def mock_send_message_fixture(send_message_side_effect: Any) -> Mock: + """Fixture to mock the send_message method.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", + side_effect=send_message_side_effect, + ) as mock_send_message: + yield mock_send_message + + @pytest.fixture def bypass_api_fixture_v1_only(bypass_api_fixture) -> None: """Bypass api for tests that require only having v1 devices.""" @@ -179,6 +202,7 @@ async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, + cleanup_map_storage: pathlib.Path, platforms: list[Platform], ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" @@ -186,3 +210,19 @@ async def setup_entry( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield mock_roborock_entry + + +@pytest.fixture +def cleanup_map_storage( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +) -> Generator[pathlib.Path]: + """Test cleanup, remove any map storage persisted during the test.""" + tmp_path = str(uuid.uuid4()) + with patch( + "homeassistant.components.roborock.roborock_storage.STORAGE_PATH", new=tmp_path + ): + storage_path = ( + pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id + ) + yield storage_path + shutil.rmtree(str(storage_path), ignore_errors=True) diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index e240dccf7eb..90886f25929 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -3,13 +3,16 @@ import copy from datetime import timedelta from http import HTTPStatus +import io from unittest.mock import patch +from PIL import Image import pytest from roborock import RoborockException +from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -32,22 +35,27 @@ async def test_floorplan_image( hass_client: ClientSessionGenerator, ) -> None: """Test floor plan map image is correctly set up.""" - # Setup calls the image parsing the first time and caches it. assert len(hass.states.async_all("image")) == 4 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - # call a second time -should return cached data + # Load the image on demand client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None - # Call a third time - this time forcing it to update - now = dt_util.utcnow() + timedelta(seconds=91) + assert body[0:4] == b"\x89PNG" + + # Call a second time - this time forcing it to update - and save new image + now = dt_util.utcnow() + timedelta(minutes=61) # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 + new_map_data = copy.deepcopy(MAP_DATA) + new_map_data.image = ImageData( + 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (2, 2)), lambda p: p + ) with ( patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", @@ -56,6 +64,10 @@ async def test_floorplan_image( patch( "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now ), + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=new_map_data, + ) as parse_map, ): async_fire_time_changed(hass, now) await hass.async_block_till_done() @@ -63,6 +75,7 @@ async def test_floorplan_image( assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None + assert parse_map.call_count == 1 async def test_floorplan_image_failed_parse( @@ -97,13 +110,101 @@ async def test_floorplan_image_failed_parse( assert not resp.ok +async def test_load_stored_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_entry: MockConfigEntry, +) -> None: + """Test that we correctly load an image from storage when it already exists.""" + img_byte_arr = io.BytesIO() + MAP_DATA.image.data.save(img_byte_arr, format="PNG") + img_bytes = img_byte_arr.getvalue() + + # Load the image on demand, which should ensure it is cached on disk + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + with patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + ) as parse_map: + # Reload the config entry so that the map is saved in storage and entities exist. + await hass.config_entries.async_reload(setup_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == img_bytes + + # Ensure that we never tried to update the map, and only used the cached image. + assert parse_map.call_count == 0 + + +async def test_fail_to_save_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle a oserror on saving an image.""" + # Reload the config entry so that the map is saved in storage and entities exist. + with patch( + "homeassistant.components.roborock.roborock_storage.Path.write_bytes", + side_effect=OSError, + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Ensure that map is still working properly. + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + + assert "Unable to write map file" in caplog.text + + +async def test_fail_to_load_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle failing to load an image.""" + with ( + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + ) as parse_map, + patch( + "homeassistant.components.roborock.roborock_storage.Path.exists", + return_value=True, + ), + patch( + "homeassistant.components.roborock.roborock_storage.Path.read_bytes", + side_effect=OSError, + ) as read_bytes, + ): + # Reload the config entry so that the map is saved in storage and entities exist. + await hass.config_entries.async_reload(setup_entry.entry_id) + await hass.async_block_till_done() + assert read_bytes.call_count == 4 + # Ensure that we never updated the map manually since we couldn't load it. + assert parse_map.call_count == 0 + assert "Unable to read map file" in caplog.text + + async def test_fail_parse_on_startup( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_roborock_entry: MockConfigEntry, bypass_api_fixture, ) -> None: - """Test that if we fail parsing on startup, we create the entity but set it as unavailable.""" + """Test that if we fail parsing on startup, we still create the entity.""" map_data = copy.deepcopy(MAP_DATA) map_data.image = None with patch( @@ -115,7 +216,28 @@ async def test_fail_parse_on_startup( assert ( image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") ) is not None - assert image_entity.state == STATE_UNAVAILABLE + assert image_entity.state + + +async def test_fail_get_map_on_startup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture, +) -> None: + """Test that if we fail getting map on startup, we can still create the entity.""" + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=None, + ), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert ( + image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") + ) is not None + assert image_entity.state async def test_fail_updating_image( diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index f4f490e68d9..efd1c3f66f4 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,6 +1,8 @@ """Test for Roborock init.""" from copy import deepcopy +from http import HTTPStatus +import pathlib from unittest.mock import patch import pytest @@ -13,12 +15,14 @@ from roborock import ( from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .mock_data import HOME_DATA from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator async def test_unload_entry( @@ -163,6 +167,60 @@ async def test_reauth_started( assert flows[0]["step_id"] == "reauth_confirm" +@pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) +async def test_remove_from_hass( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + cleanup_map_storage: pathlib.Path, +) -> None: + """Test that removing from hass removes any existing images.""" + + # Ensure some image content is cached + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + assert cleanup_map_storage.exists() + paths = list(cleanup_map_storage.walk()) + assert len(paths) == 3 # One map image and two directories + + await hass.config_entries.async_remove(setup_entry.entry_id) + # After removal, directories should be empty. + assert not cleanup_map_storage.exists() + + +@pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) +async def test_oserror_remove_image( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + cleanup_map_storage: pathlib.Path, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle failing to remove an image.""" + + # Ensure some image content is cached + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + assert cleanup_map_storage.exists() + paths = list(cleanup_map_storage.walk()) + assert len(paths) == 3 # One map image and two directories + + with patch( + "homeassistant.components.roborock.roborock_storage.shutil.rmtree", + side_effect=OSError, + ): + await hass.config_entries.async_remove(setup_entry.entry_id) + assert "Unable to remove map files" in caplog.text + + async def test_not_supported_protocol( hass: HomeAssistant, bypass_api_fixture, diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 2476bfe497c..e2df9a3498f 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -1,6 +1,6 @@ """Test Roborock Switch platform.""" -from unittest.mock import patch +from unittest.mock import Mock import pytest import roborock @@ -29,6 +29,7 @@ def platforms() -> list[Platform]: ) async def test_update_success( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -36,27 +37,22 @@ async def test_update_success( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command" - ) as mock_send_message: - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - service_data=None, - blocking=True, - target={"entity_id": entity_id}, - ) + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message" - ) as mock_send_message: - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - service_data=None, - blocking=True, - target={"entity_id": entity_id}, - ) + mock_send_message.reset_mock() + await hass.services.async_call( + "switch", + SERVICE_TURN_OFF, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once @@ -67,8 +63,12 @@ async def test_update_success( ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_OFF), ], ) +@pytest.mark.parametrize( + "send_message_side_effect", [roborock.exceptions.RoborockTimeout] +) async def test_update_failed( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -78,10 +78,6 @@ async def test_update_failed( # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", - side_effect=roborock.exceptions.RoborockTimeout, - ) as mock_send_message, pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), ): await hass.services.async_call( diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index eb48e8e537f..9c0a53893ed 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -1,7 +1,7 @@ """Test Roborock Time platform.""" from datetime import time -from unittest.mock import patch +from unittest.mock import Mock import pytest import roborock @@ -29,6 +29,7 @@ def platforms() -> list[Platform]: ) async def test_update_success( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -36,16 +37,13 @@ async def test_update_success( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command" - ) as mock_send_message: - await hass.services.async_call( - "time", - SERVICE_SET_VALUE, - service_data={"time": time(hour=1, minute=1)}, - blocking=True, - target={"entity_id": entity_id}, - ) + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=1, minute=1)}, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once @@ -55,8 +53,12 @@ async def test_update_success( ("time.roborock_s7_maxv_do_not_disturb_begin"), ], ) +@pytest.mark.parametrize( + "send_message_side_effect", [roborock.exceptions.RoborockTimeout] +) async def test_update_failure( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -64,13 +66,7 @@ async def test_update_failure( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", - side_effect=roborock.exceptions.RoborockTimeout, - ) as mock_send_message, - pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), - ): + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): await hass.services.async_call( "time", SERVICE_SET_VALUE, diff --git a/tests/components/roku/test_select.py b/tests/components/roku/test_select.py index 78cd65250f8..a79a23782ce 100644 --- a/tests/components/roku/test_select.py +++ b/tests/components/roku/test_select.py @@ -22,7 +22,7 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index ec12031ef96..105ef0f25ad 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -21,7 +21,7 @@ from samsungtvws.exceptions import ResponseError from samsungtvws.remote import ChannelEmitCommand from homeassistant.components.samsungtv.const import WEBSOCKET_SSL_PORT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 1a7c8713b17..3d9633bbf96 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -78,7 +78,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_wait_config_entry_reload, setup_samsungtv_entry from .const import ( diff --git a/tests/components/schlage/test_init.py b/tests/components/schlage/test_init.py index 57a139e582e..97da66c7e93 100644 --- a/tests/components/schlage/test_init.py +++ b/tests/components/schlage/test_init.py @@ -12,7 +12,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.schlage.const import DOMAIN, UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceRegistry from . import MockSchlageConfigEntry diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 248ada605cc..3b0bff7e82e 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -42,8 +42,7 @@ from homeassistant.helpers.script import ( ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.setup import async_setup_component -from homeassistant.util import yaml as yaml_util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index f50e92bc9df..f35c9520f71 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import load_json from .common import UNITS_OF_MEASUREMENT, MockSensor diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index fcf5a711c46..615960defbb 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -40,7 +40,7 @@ from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .common import MockSensor diff --git a/tests/components/sensor/test_recorder_missing_stats.py b/tests/components/sensor/test_recorder_missing_stats.py index 449ffd55727..fd28a7052a5 100644 --- a/tests/components/sensor/test_recorder_missing_stats.py +++ b/tests/components/sensor/test_recorder_missing_stats.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.util import session_scope from homeassistant.core import CoreState from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_test_home_assistant from tests.components.recorder.common import ( diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py index 5db6347a832..ba03f6fc804 100644 --- a/tests/components/sighthound/test_image_processing.py +++ b/tests/components/sighthound/test_image_processing.py @@ -11,7 +11,7 @@ import pytest import simplehound.core as hound from homeassistant.components.image_processing import DOMAIN as IP_DOMAIN, SERVICE_SCAN -import homeassistant.components.sighthound.image_processing as sh +from homeassistant.components.sighthound import image_processing as sh from homeassistant.const import ( ATTR_ENTITY_ID, CONF_API_KEY, diff --git a/tests/components/smlight/fixtures/info.json b/tests/components/smlight/fixtures/info.json index e3defb4410e..b94fdc3d61c 100644 --- a/tests/components/smlight/fixtures/info.json +++ b/tests/components/smlight/fixtures/info.json @@ -15,5 +15,17 @@ "zb_hw": "CC2652P7", "zb_ram_size": 152, "zb_version": "20240314", - "zb_type": 0 + "zb_type": 0, + "radios": [ + { + "chip_index": 0, + "zb_hw": "CC2652P7", + "zb_version": "20240314", + "zb_type": 0, + "zb_channel": 0, + "zb_ram_size": 152, + "zb_flash_size": 704, + "radioModes": [true, true, true, false, false] + } + ] } diff --git a/tests/components/smlight/snapshots/test_diagnostics.ambr b/tests/components/smlight/snapshots/test_diagnostics.ambr index 97177de1704..5ee6cd19676 100644 --- a/tests/components/smlight/snapshots/test_diagnostics.ambr +++ b/tests/components/smlight/snapshots/test_diagnostics.ambr @@ -10,6 +10,24 @@ 'hostname': 'SLZB-06p7', 'legacy_api': 0, 'model': 'SLZB-06p7', + 'radios': list([ + dict({ + 'chip_index': 0, + 'radioModes': list([ + True, + True, + True, + False, + False, + ]), + 'zb_channel': 0, + 'zb_flash_size': 704, + 'zb_hw': 'CC2652P7', + 'zb_ram_size': 152, + 'zb_type': 0, + 'zb_version': '20240314', + }), + ]), 'ram_total': 296, 'sw_version': 'v2.3.6', 'wifi_mode': 0, diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index afc53932fb0..d0c5e494ae8 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -8,9 +8,14 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, Smlig import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.smlight.const import ( + DOMAIN, + SCAN_FIRMWARE_INTERVAL, + SCAN_INTERVAL, +) +from homeassistant.components.update import ATTR_INSTALLED_VERSION from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.issue_registry import IssueRegistry @@ -73,6 +78,41 @@ async def test_async_setup_missing_credentials( assert progress[0]["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" +async def test_async_setup_no_internet( + hass: HomeAssistant, + mock_config_entry_host: MockConfigEntry, + mock_smlight_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we still load integration when no internet is available.""" + mock_smlight_client.get_firmware_version.side_effect = SmlightConnectionError + + await setup_integration(hass, mock_config_entry_host) + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + mock_smlight_client.get_firmware_version.side_effect = None + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_ON + assert entity.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" + + @pytest.mark.parametrize("error", [SmlightConnectionError, SmlightAuthError]) async def test_update_failed( hass: HomeAssistant, diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 0bb2e34d7ca..4fca7369116 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -81,7 +81,7 @@ async def test_update_setup( mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test setup of SMLIGHT switches.""" + """Test setup of SMLIGHT update entities.""" entry = await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 3fc8da6a952..a7ad2f4cb82 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -21,7 +21,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockSoCo, SoCoMockFactory diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index 2e20aaa259c..1dd30c425b3 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.speedtestdotnet.coordinator import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/srp_energy/conftest.py b/tests/components/srp_energy/conftest.py index b612bc9f3f3..b1d5b958d47 100644 --- a/tests/components/srp_energy/conftest.py +++ b/tests/components/srp_energy/conftest.py @@ -12,7 +12,7 @@ import pytest from homeassistant.components.srp_energy.const import DOMAIN, PHOENIX_TIME_ZONE from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import MOCK_USAGE, TEST_CONFIG_HOME diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 839509e756b..56623b51bb5 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -39,7 +39,7 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/steamist/test_switch.py b/tests/components/steamist/test_switch.py index a20bebc4052..cd62c18590a 100644 --- a/tests/components/steamist/test_switch.py +++ b/tests/components/steamist/test_switch.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( MOCK_ASYNC_GET_STATUS_ACTIVE, diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index cd48fd94c24..c96b7d9427f 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -19,7 +19,7 @@ from homeassistant.components.stream.const import ( from homeassistant.components.stream.core import Orientation, Part from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( FAKE_TIME, diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index 8e079cded45..7c856180f77 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -21,7 +21,7 @@ from homeassistant.components.stream.fmp4utils import find_box from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DefaultSegment as Segment, diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index e18ea8fd398..84d2fde97f7 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .api_responses import TEST_VIN_2_EV, VEHICLE_DATA, VEHICLE_STATUS_EV diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index a30076d6d3c..3896498bbb0 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.sun import entity from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index cb97ae565c7..59e4e4c700b 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -10,9 +10,9 @@ import pytest from homeassistant.components import sun from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 303ca3b80cd..a7aeae25ac7 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index 7c4f434b0a4..5c5737804e1 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 08e6ab6d0f6..81f8a93611d 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 0cd119cf015..cdbc5934c5f 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -208,8 +208,8 @@ async def test_agents_info( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "synology_dsm.Mock Title"}, - {"agent_id": "backup.local"}, + {"agent_id": "synology_dsm.mocked_syno_dsm_entry", "name": "Mock Title"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -231,7 +231,7 @@ async def test_agents_not_loaded( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "backup.local"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -251,8 +251,8 @@ async def test_agents_on_unload( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "synology_dsm.Mock Title"}, - {"agent_id": "backup.local"}, + {"agent_id": "synology_dsm.mocked_syno_dsm_entry", "name": "Mock Title"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -269,7 +269,7 @@ async def test_agents_on_unload( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "backup.local"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -290,6 +290,12 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [], + "agents": { + "synology_dsm.mocked_syno_dsm_entry": { + "protected": True, + "size": 13916160, + } + }, "backup_id": "abcd12ef", "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, @@ -297,9 +303,6 @@ async def test_agents_list_backups( "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", "name": "Automatic backup 2025.2.0.dev0", - "protected": True, - "size": 13916160, - "agent_ids": ["synology_dsm.Mock Title"], "failed_agent_ids": [], "with_automatic_settings": None, } @@ -323,12 +326,16 @@ async def test_agents_list_backups_error( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to list backups"}, + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to list backups" + }, "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } @@ -353,6 +360,12 @@ async def test_agents_list_backups_disabled_filestation( "abcd12ef", { "addons": [], + "agents": { + "synology_dsm.mocked_syno_dsm_entry": { + "protected": True, + "size": 13916160, + } + }, "backup_id": "abcd12ef", "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, @@ -360,9 +373,6 @@ async def test_agents_list_backups_disabled_filestation( "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", "name": "Automatic backup 2025.2.0.dev0", - "protected": True, - "size": 13916160, - "agent_ids": ["synology_dsm.Mock Title"], "failed_agent_ids": [], "with_automatic_settings": None, }, @@ -429,7 +439,9 @@ async def test_agents_get_backup_error( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to list backups"}, + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to list backups" + }, "backup": None, } @@ -462,7 +474,7 @@ async def test_agents_download( backup_id = "abcd12ef" resp = await client.get( - f"/api/backup/download/{backup_id}?agent_id=synology_dsm.Mock Title" + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.mocked_syno_dsm_entry" ) assert resp.status == 200 assert await resp.content.read() == b"backup data" @@ -482,7 +494,7 @@ async def test_agents_download_not_existing( ) resp = await client.get( - f"/api/backup/download/{backup_id}?agent_id=synology_dsm.Mock Title" + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.mocked_syno_dsm_entry" ) assert resp.reason == "Internal Server Error" assert resp.status == 500 @@ -524,7 +536,7 @@ async def test_agents_upload( mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) fetch_backup.return_value = test_backup resp = await client.post( - "/api/backup/upload?agent_id=synology_dsm.Mock Title", + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", data={"file": StringIO("test")}, ) @@ -578,7 +590,7 @@ async def test_agents_upload_error( SynologyDSMAPIErrorException("api", "500", "error") ) resp = await client.post( - "/api/backup/upload?agent_id=synology_dsm.Mock Title", + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", data={"file": StringIO("test")}, ) @@ -609,7 +621,7 @@ async def test_agents_upload_error( ] resp = await client.post( - "/api/backup/upload?agent_id=synology_dsm.Mock Title", + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", data={"file": StringIO("test")}, ) @@ -674,7 +686,9 @@ async def test_agents_delete_not_existing( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to delete the backup"} + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to delete the backup" + } } @@ -701,7 +715,9 @@ async def test_agents_delete_error( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to delete the backup"} + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to delete the backup" + } } mock: AsyncMock = setup_dsm_with_filestation.file.delete_file assert len(mock.mock_calls) == 1 diff --git a/tests/components/tankerkoenig/test_coordinator.py b/tests/components/tankerkoenig/test_coordinator.py index 3ba0dc31c5f..ff2ceca7442 100644 --- a/tests/components/tankerkoenig/test_coordinator.py +++ b/tests/components/tankerkoenig/test_coordinator.py @@ -25,7 +25,7 @@ from homeassistant.const import ATTR_ID, CONF_SHOW_ON_MAP, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONFIG_DATA diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 5abb9ab9bf2..ff951e058cb 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -13,6 +13,7 @@ from hatasmota.utils import ( ) import pytest +from homeassistant import core as ha from homeassistant.components.tasmota.const import DEFAULT_PREFIX from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -22,9 +23,8 @@ from homeassistant.const import ( STATE_UNKNOWN, Platform, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( DEFAULT_CONFIG, diff --git a/tests/components/tcp/test_sensor.py b/tests/components/tcp/test_sensor.py index 27003df46cd..ade4b9f93d4 100644 --- a/tests/components/tcp/test_sensor.py +++ b/tests/components/tcp/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import call, patch import pytest -import homeassistant.components.tcp.common as tcp +from homeassistant.components.tcp import common as tcp from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/temper/test_sensor.py b/tests/components/temper/test_sensor.py index d1e74f1ab0f..445adc0b5bd 100644 --- a/tests/components/temper/test_sensor.py +++ b/tests/components/temper/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import Mock, patch from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 3e3a629b4be..a7ee953bb09 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index cb4e83d934c..dd008a27822 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -149,6 +149,69 @@ async def test_inverted_binary_sensor( ) +async def test_reload_template_when_blueprint_changes(hass: HomeAssistant) -> None: + """Test a template is updated at reload if the blueprint has changed.""" + hass.states.async_set("binary_sensor.foo", "on", {"friendly_name": "Foo"}) + config = { + DOMAIN: [ + { + "use_blueprint": { + "path": "inverted_binary_sensor.yaml", + "input": {"reference_entity": "binary_sensor.foo"}, + }, + "name": "Inverted foo", + }, + ] + } + with patch_blueprint( + "inverted_binary_sensor.yaml", + BUILTIN_BLUEPRINT_FOLDER / "inverted_binary_sensor.yaml", + ): + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set("binary_sensor.foo", "off", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.foo").state == "off" + + inverted = hass.states.get("binary_sensor.inverted_foo") + assert inverted + assert inverted.state == "on" + + # Reload the automations without any change, but with updated blueprint + blueprint_config = yaml_util.load_yaml( + BUILTIN_BLUEPRINT_FOLDER / "inverted_binary_sensor.yaml" + ) + blueprint_config["binary_sensor"]["state"] = "{{ states(reference_entity) }}" + with ( + patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ), + patch( + "homeassistant.components.blueprint.models.yaml_util.load_yaml_dict", + autospec=True, + return_value=blueprint_config, + ), + ): + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) + + hass.states.async_set("binary_sensor.foo", "off", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + not_inverted = hass.states.get("binary_sensor.inverted_foo") + assert not_inverted + assert not_inverted.state == "off" + + hass.states.async_set("binary_sensor.foo", "on", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + not_inverted = hass.states.get("binary_sensor.inverted_foo") + assert not_inverted + assert not_inverted.state == "on" + + async def test_domain_blueprint(hass: HomeAssistant) -> None: """Test DomainBlueprint services.""" reload_handler_calls = async_mock_service(hass, DOMAIN, SERVICE_RELOAD) diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 929a890ab38..3bf91549114 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ATTR_COMPONENT, async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index a131f5f606b..49b89b61d34 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, ServiceCall, callback from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/tesla_fleet/snapshots/test_switch.ambr b/tests/components/tesla_fleet/snapshots/test_switch.ambr index 2d69a7d314a..43d59a9da85 100644 --- a/tests/components/tesla_fleet/snapshots/test_switch.ambr +++ b/tests/components/tesla_fleet/snapshots/test_switch.ambr @@ -262,7 +262,7 @@ 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_user_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRWXF7EK4KC700000-charge_state_user_charge_enable_request', 'unit_of_measurement': None, }) @@ -278,7 +278,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch[switch.test_defrost-entry] @@ -456,7 +456,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch_alt[switch.test_defrost-statealt] diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index 9533b7e691e..e4499d6e308 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -14,7 +14,7 @@ from homeassistant.components.tesla_wall_connector.const import ( ) from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py index b6b9df7eb4b..59727926f03 100644 --- a/tests/components/teslemetry/__init__.py +++ b/tests/components/teslemetry/__init__.py @@ -14,7 +14,9 @@ from .const import CONFIG from tests.common import MockConfigEntry -async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = None): +async def setup_platform( + hass: HomeAssistant, platforms: list[Platform] | None = None +) -> MockConfigEntry: """Set up the Teslemetry platform.""" mock_entry = MockConfigEntry( @@ -32,6 +34,19 @@ async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = return mock_entry +async def reload_platform( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] | None = None +): + """Reload the Teslemetry platform.""" + + if platforms is None: + await hass.config_entries.async_reload(entry.entry_id) + else: + with patch("homeassistant.components.teslemetry.PLATFORMS", platforms): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + def assert_entities( hass: HomeAssistant, entry_id: str, diff --git a/tests/components/teslemetry/fixtures/vehicle_data_alt.json b/tests/components/teslemetry/fixtures/vehicle_data_alt.json index 25b3878f4dd..ec524614d49 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data_alt.json +++ b/tests/components/teslemetry/fixtures/vehicle_data_alt.json @@ -35,7 +35,7 @@ "charge_port_cold_weather_mode": false, "charge_port_color": "", "charge_port_door_open": true, - "charge_port_latch": "Engaged", + "charge_port_latch": null, "charge_rate": 0, "charger_actual_current": 0, "charger_phases": null, diff --git a/tests/components/teslemetry/snapshots/test_lock.ambr b/tests/components/teslemetry/snapshots/test_lock.ambr index 2130c4d9574..bb5693fe3ab 100644 --- a/tests/components/teslemetry/snapshots/test_lock.ambr +++ b/tests/components/teslemetry/snapshots/test_lock.ambr @@ -93,3 +93,109 @@ 'state': 'unlocked', }) # --- +# name: test_lock_alt[lock.test_charge_cable_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_charge_cable_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge cable lock', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_port_latch', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_latch', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_alt[lock.test_charge_cable_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge cable lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_charge_cable_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock_alt[lock.test_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_locked', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_alt[lock.test_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock_streaming[lock.test_charge_cable_lock-locked] + 'locked' +# --- +# name: test_lock_streaming[lock.test_charge_cable_lock-unlocked] + 'unlocked' +# --- +# name: test_lock_streaming[lock.test_lock-locked] + 'locked' +# --- +# name: test_lock_streaming[lock.test_lock-unlocked] + 'unlocked' +# --- diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index 0f30daf635e..8e8f10397d0 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -229,3 +229,9 @@ 'state': '80', }) # --- +# name: test_number_streaming[number.test_charge_current-state] + '24' +# --- +# name: test_number_streaming[number.test_charge_limit-state] + '99' +# --- diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr index 5693d4bdd5e..b34d9c65393 100644 --- a/tests/components/teslemetry/snapshots/test_switch.ambr +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -262,7 +262,7 @@ 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_user_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRW3F7EK4NC700000-charge_state_user_charge_enable_request', 'unit_of_measurement': None, }) @@ -278,7 +278,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch[switch.test_defrost-entry] @@ -456,7 +456,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch_alt[switch.test_defrost-statealt] diff --git a/tests/components/teslemetry/test_lock.py b/tests/components/teslemetry/test_lock.py index f7c9fea1400..848eee82c39 100644 --- a/tests/components/teslemetry/test_lock.py +++ b/tests/components/teslemetry/test_lock.py @@ -1,9 +1,10 @@ """Test the Teslemetry lock platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream.const import Signal from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, @@ -16,14 +17,15 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform -from .const import COMMAND_OK +from . import assert_entities, reload_platform, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT async def test_lock( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the lock entities are correct.""" @@ -31,6 +33,20 @@ async def test_lock( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +async def test_lock_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, +) -> None: + """Tests that the lock entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.LOCK]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + async def test_lock_services( hass: HomeAssistant, ) -> None: @@ -91,3 +107,60 @@ async def test_lock_services( state = hass.states.get(entity_id) assert state.state == LockState.UNLOCKED call.assert_called_once() + + +async def test_lock_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the lock entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.LOCK]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCKED: True, + Signal.CHARGE_PORT_LATCH: "ChargePortLatchEngaged", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.LOCK]) + + # Assert the entities restored their values + for entity_id in ( + "lock.test_lock", + "lock.test_charge_cable_lock", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-locked") + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCKED: False, + Signal.CHARGE_PORT_LATCH: "ChargePortLatchDisengaged", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.LOCK]) + + # Assert the entities restored their values + for entity_id in ( + "lock.test_lock", + "lock.test_charge_cable_lock", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-unlocked") diff --git a/tests/components/teslemetry/test_number.py b/tests/components/teslemetry/test_number.py index 65c03514d22..95eed5a3f1e 100644 --- a/tests/components/teslemetry/test_number.py +++ b/tests/components/teslemetry/test_number.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.number import ( ATTR_VALUE, @@ -14,7 +15,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA_ALT @@ -23,6 +24,7 @@ async def test_number( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the number entities are correct.""" @@ -100,3 +102,38 @@ async def test_number_services( state = hass.states.get(entity_id) assert state.state == "88" call.assert_called_once() + + +async def test_number_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the number entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.NUMBER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.CHARGE_CURRENT_REQUEST: 24, + Signal.CHARGE_CURRENT_REQUEST_MAX: 32, + Signal.CHARGE_LIMIT_SOC: 99, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.NUMBER]) + + # Assert the entities restored their values + for entity_id in ( + "number.test_charge_current", + "number.test_charge_limit", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/tessie/snapshots/test_switch.ambr b/tests/components/tessie/snapshots/test_switch.ambr index 3b7a3623de8..35e36010830 100644 --- a/tests/components/tessie/snapshots/test_switch.ambr +++ b/tests/components/tessie/snapshots/test_switch.ambr @@ -119,7 +119,7 @@ 'platform': 'tessie', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'VINVINVIN-charge_state_charge_enable_request', 'unit_of_measurement': None, }) @@ -351,6 +351,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index 499e529b2e8..690ad7d1ab4 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -20,7 +20,7 @@ from .common import RESPONSE_OK, assert_entities, setup_platform async def test_switches( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry ) -> None: - """Tests that the switche entities are correct.""" + """Tests that the switch entities are correct.""" entry = await setup_platform(hass, [Platform.SWITCH]) diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index ddeec48b3d2..3daa0314cbd 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.time_date.const import OPTION_TYPES from homeassistant.core import HomeAssistant from homeassistant.helpers import event -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import load_int diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index b4b6b13d8e3..8b9a81d7542 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -7,10 +7,10 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.sun import get_astral_event_date, get_astral_event_next from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/tomato/test_device_tracker.py b/tests/components/tomato/test_device_tracker.py index f50d999548f..e4f08f55dba 100644 --- a/tests/components/tomato/test_device_tracker.py +++ b/tests/components/tomato/test_device_tracker.py @@ -8,7 +8,7 @@ import requests_mock import voluptuous as vol from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN -import homeassistant.components.tomato.device_tracker as tomato +from homeassistant.components.tomato import device_tracker as tomato from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 008d25a3dcb..ac5bb347765 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -2,7 +2,7 @@ from collections import namedtuple from dataclasses import replace -from datetime import datetime +from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -16,7 +16,9 @@ from kasa import ( ThermostatState, ) from kasa.interfaces import Fan, Light, LightEffect, LightState, Thermostat +from kasa.smart.modules import Speaker from kasa.smart.modules.alarm import Alarm +from kasa.smart.modules.clean import AreaUnit, Clean, ErrorCode, Status from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera from syrupy import SnapshotAssertion @@ -58,6 +60,7 @@ def _load_feature_fixtures(): FEATURES_FIXTURE = _load_feature_fixtures() +FIXTURE_ENUM_TYPES = {"CleanErrorCode": ErrorCode, "CleanAreaUnit": AreaUnit} async def setup_platform_for_device( @@ -103,7 +106,7 @@ async def snapshot_platform( if entity_entry.translation_key: key = f"component.{DOMAIN}.entity.{entity_entry.domain}.{entity_entry.translation_key}.name" single_device_class_translation = False - if key not in translations and entity_entry.original_device_class: + if key not in translations: # No name translation if entity_entry.original_device_class not in unique_device_classes: single_device_class_translation = True unique_device_classes.append(entity_entry.original_device_class) @@ -195,16 +198,33 @@ def _mocked_device( ) device.features = device_features - # Add modules after features so modules can add required features + # Add modules after features so modules can add any required features if modules: device.modules = { module_name: MODULE_TO_MOCK_GEN[module_name](device) for module_name in modules } + # module features are accessed from a module via get_feature which is + # keyed on the module attribute name. Usually this is the same as the + # feature.id but not always so accept overrides. + module_features = { + mod_key if (mod_key := v.expected_module_key) else k: v + for k, v in device_features.items() + } for mod in device.modules.values(): - mod.get_feature.side_effect = device_features.get - mod.has_feature.side_effect = lambda id: id in device_features + # Some tests remove the feature from device_features to test missing + # features, so check the key is still present there. + mod.get_feature.side_effect = ( + lambda mod_id: mod_feat + if (mod_feat := module_features.get(mod_id)) + and mod_feat.id in device_features + else None + ) + mod.has_feature.side_effect = ( + lambda mod_id: (mod_feat := module_features.get(mod_id)) + and mod_feat.id in device_features + ) device.parent = None device.children = [] @@ -243,6 +263,7 @@ def _mocked_feature( unit=None, minimum_value=None, maximum_value=None, + expected_module_key=None, ) -> Feature: """Get a mocked feature. @@ -255,6 +276,17 @@ def _mocked_feature( if fixture := FEATURES_FIXTURE.get(id): # copy the fixture so tests do not interfere with each other fixture = dict(fixture) + + if enum_type := fixture.get("enum_type"): + val = FIXTURE_ENUM_TYPES[enum_type](fixture["value"]) + fixture["value"] = val + if timedelta_type := fixture.get("timedelta_type"): + fixture["value"] = timedelta(**{timedelta_type: fixture["value"]}) + + if unit_enum_type := fixture.get("unit_enum_type"): + val = FIXTURE_ENUM_TYPES[unit_enum_type](fixture["unit"]) + fixture["unit"] = val + else: assert require_fixture is False, ( f"No fixture defined for feature {id} and require_fixture is True" @@ -284,6 +316,16 @@ def _mocked_feature( # select feature.choices = choices or fixture.get("choices") + # module features are accessed from a module via get_feature which is + # keyed on the module attribute name. Usually this is the same as the + # feature.id but not always. module_key indicates the key of the feature + # in the module. + feature.expected_module_key = ( + mod_key + if (mod_key := fixture.get("expected_module_key", expected_module_key)) + else None + ) + return feature @@ -400,6 +442,43 @@ def _mocked_thermostat_module(device): return therm +def _mocked_clean_module(device): + clean = MagicMock(auto_spec=Clean, name="Mocked clean") + + # methods + clean.start = AsyncMock() + clean.pause = AsyncMock() + clean.resume = AsyncMock() + clean.return_home = AsyncMock() + clean.set_fan_speed_preset = AsyncMock() + + # properties + clean.fan_speed_preset = "Max" + clean.error = ErrorCode.Ok + clean.battery = 100 + clean.status = Status.Charged + + # Need to manually create the fan speed preset feature, + # as we are going to read its choices through it + device.features["vacuum_fan_speed"] = _mocked_feature( + "vacuum_fan_speed", + type_=Feature.Type.Choice, + category=Feature.Category.Config, + choices=["Quiet", "Max"], + value="Max", + expected_module_key="fan_speed_preset", + ) + + return clean + + +def _mocked_speaker_module(device): + speaker = MagicMock(auto_spec=Speaker, name="Mocked speaker") + speaker.locate = AsyncMock() + + return speaker + + def _mocked_strip_children(features=None, alias=None) -> list[Device]: plug0 = _mocked_device( alias="Plug0" if alias is None else alias, @@ -469,6 +548,8 @@ MODULE_TO_MOCK_GEN = { Module.Alarm: _mocked_alarm_module, Module.Camera: _mocked_camera_module, Module.Thermostat: _mocked_thermostat_module, + Module.Clean: _mocked_clean_module, + Module.Speaker: _mocked_speaker_module, } diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index 3d27e63b06a..81277ddd3ae 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -195,6 +195,11 @@ "type": "BinarySensor", "category": "Info" }, + "overloaded": { + "value": false, + "type": "BinarySensor", + "category": "Info" + }, "battery_low": { "value": false, "type": "BinarySensor", @@ -284,6 +289,12 @@ "minimum_value": -10, "maximum_value": 10 }, + "power_protection_threshold": { + "value": 100, + "type": "Number", + "category": "Config", + "minimum_value": 0 + }, "target_temperature": { "value": false, "type": "Number", @@ -330,6 +341,61 @@ "Connection 2" ] }, + "clean_time": { + "type": "Sensor", + "category": "Info", + "value": 12, + "timedelta_type": "minutes" + }, + "clean_area": { + "type": "Sensor", + "category": "Info", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "clean_progress": { + "type": "Sensor", + "category": "Info", + "value": 30, + "unit": "%" + }, + "total_clean_time": { + "type": "Sensor", + "category": "Debug", + "value": 120, + "timedelta_type": "minutes" + }, + "total_clean_area": { + "type": "Sensor", + "category": "Debug", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "last_clean_time": { + "type": "Sensor", + "category": "Debug", + "value": 60, + "timedelta_type": "minutes" + }, + "last_clean_area": { + "type": "Sensor", + "category": "Debug", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "last_clean_timestamp": { + "type": "Sensor", + "category": "Debug", + "value": "2024-06-24 10:03:11.046643+01:00" + }, + "total_clean_count": { + "type": "Sensor", + "category": "Debug", + "value": 12 + }, "alarm_volume": { "value": "normal", "type": "Choice", @@ -370,5 +436,116 @@ "value": 10, "type": "Number", "category": "Config" + }, + "clean_count": { + "value": 1, + "type": "Number", + "category": "Config" + }, + "carpet_boost": { + "value": true, + "type": "Switch", + "category": "Config" + }, + "vacuum_error": { + "value": 0, + "type": "Sensor", + "category": "Info", + "enum_type": "CleanErrorCode" + }, + "pair": { + "value": "", + "type": "Action", + "category": "Config" + }, + "unpair": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "main_brush_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "side_brush_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "sensor_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "filter_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "charging_contacts_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "main_brush_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "main_brush_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "side_brush_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "side_brush_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "filter_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "filter_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "sensor_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "sensor_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "charging_contacts_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "charging_contacts_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" } } diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index e16d4409511..125592b053c 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -300,6 +300,53 @@ 'state': 'off', }) # --- +# name: test_states[binary_sensor.my_device_overloaded-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_device_overloaded', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overloaded', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overloaded', + 'unique_id': '123456789ABCDEFGH_overloaded', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_overloaded-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'my_device Overloaded', + }), + 'context': , + 'entity_id': 'binary_sensor.my_device_overloaded', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_states[binary_sensor.my_device_temperature_warning-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index de626cd5818..c0c74e11923 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -1,4 +1,50 @@ # serializer version: 1 +# name: test_states[button.my_device_pair_new_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_pair_new_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pair new device', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pair', + 'unique_id': '123456789ABCDEFGH_pair', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_pair_new_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Pair new device', + }), + 'context': , + 'entity_id': 'button.my_device_pair_new_device', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[button.my_device_pan_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -91,6 +137,171 @@ 'state': 'unknown', }) # --- +# name: test_states[button.my_device_reset_charging_contacts_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_charging_contacts_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset charging contacts consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_reset', + 'unique_id': '123456789ABCDEFGH_charging_contacts_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_filter_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_filter_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset filter consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_reset', + 'unique_id': '123456789ABCDEFGH_filter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_main_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_main_brush_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset main brush consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_reset', + 'unique_id': '123456789ABCDEFGH_main_brush_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_sensor_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_sensor_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset sensor consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_reset', + 'unique_id': '123456789ABCDEFGH_sensor_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_side_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_side_brush_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset side brush consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_reset', + 'unique_id': '123456789ABCDEFGH_side_brush_reset', + 'unit_of_measurement': None, + }) +# --- # name: test_states[button.my_device_restart-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -308,6 +519,39 @@ 'state': 'unknown', }) # --- +# name: test_states[button.my_device_unpair_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_unpair_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Unpair device', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'unpair', + 'unique_id': '123456789ABCDEFGH_unpair', + 'unit_of_measurement': None, + }) +# --- # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index df5ef71bf44..4bdb92aeab6 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -35,6 +35,61 @@ 'via_device_id': None, }) # --- +# name: test_states[number.my_device_clean_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_device_clean_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clean count', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_count', + 'unique_id': '123456789ABCDEFGH_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_clean_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Clean count', + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.my_device_clean_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- # name: test_states[number.my_device_pan_degrees-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -90,6 +145,61 @@ 'state': '10', }) # --- +# name: test_states[number.my_device_power_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_device_power_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power protection', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_protection_threshold', + 'unique_id': '123456789ABCDEFGH_power_protection_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_power_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Power protection', + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.my_device_power_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_states[number.my_device_smooth_off-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 461e8c6e505..0d1cc9a03e4 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -166,6 +166,227 @@ 'state': '85', }) # --- +# name: test_states[sensor.my_device_charging_contacts_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_charging_contacts_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging contacts remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_remaining', + 'unique_id': '123456789ABCDEFGH_charging_contacts_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_charging_contacts_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_charging_contacts_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging contacts used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_used', + 'unique_id': '123456789ABCDEFGH_charging_contacts_used', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_area', + 'unique_id': '123456789ABCDEFGH_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'area', + 'friendly_name': 'my_device Cleaning area', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_device_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_states[sensor.my_device_cleaning_progress-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_progress', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning progress', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_progress', + 'unique_id': '123456789ABCDEFGH_clean_progress', + 'unit_of_measurement': '%', + }) +# --- +# name: test_states[sensor.my_device_cleaning_progress-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Cleaning progress', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_device_cleaning_progress', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_states[sensor.my_device_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_time', + 'unique_id': '123456789ABCDEFGH_clean_time', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'my_device Cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_device_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.00', + }) +# --- # name: test_states[sensor.my_device_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -307,6 +528,154 @@ 'unit_of_measurement': None, }) # --- +# name: test_states[sensor.my_device_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'sidebrushstuck', + 'mainbrushstuck', + 'wheelblocked', + 'trapped', + 'trappedcliff', + 'dustbinremoved', + 'unabletomove', + 'lidarblocked', + 'unabletofinddock', + 'batterylow', + 'unknowninternal', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_error', + 'unique_id': '123456789ABCDEFGH_vacuum_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'my_device Error', + 'options': list([ + 'ok', + 'sidebrushstuck', + 'mainbrushstuck', + 'wheelblocked', + 'trapped', + 'trappedcliff', + 'dustbinremoved', + 'unabletomove', + 'lidarblocked', + 'unabletofinddock', + 'batterylow', + 'unknowninternal', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_device_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_states[sensor.my_device_filter_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_filter_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_remaining', + 'unique_id': '123456789ABCDEFGH_filter_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_filter_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_filter_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_used', + 'unique_id': '123456789ABCDEFGH_filter_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -358,6 +727,111 @@ 'state': '12', }) # --- +# name: test_states[sensor.my_device_last_clean_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_clean_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last clean start', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_timestamp', + 'unique_id': '123456789ABCDEFGH_last_clean_timestamp', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_last_cleaned_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_cleaned_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last cleaned area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_area', + 'unique_id': '123456789ABCDEFGH_last_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_last_cleaned_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_cleaned_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last cleaned time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_time', + 'unique_id': '123456789ABCDEFGH_last_clean_time', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_last_water_leak_alert-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -405,6 +879,78 @@ 'state': '2024-06-24T09:03:11+00:00', }) # --- +# name: test_states[sensor.my_device_main_brush_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_main_brush_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main brush remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_remaining', + 'unique_id': '123456789ABCDEFGH_main_brush_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_main_brush_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_main_brush_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main brush used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_used', + 'unique_id': '123456789ABCDEFGH_main_brush_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_on_since-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -471,6 +1017,150 @@ 'unit_of_measurement': '%', }) # --- +# name: test_states[sensor.my_device_sensor_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_sensor_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sensor remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_remaining', + 'unique_id': '123456789ABCDEFGH_sensor_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_sensor_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_sensor_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sensor used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_used', + 'unique_id': '123456789ABCDEFGH_sensor_used', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_side_brush_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_side_brush_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side brush remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_remaining', + 'unique_id': '123456789ABCDEFGH_side_brush_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_side_brush_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_side_brush_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side brush used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_used', + 'unique_id': '123456789ABCDEFGH_side_brush_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_signal_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -731,6 +1421,111 @@ 'state': '5.23', }) # --- +# name: test_states[sensor.my_device_total_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_area', + 'unique_id': '123456789ABCDEFGH_total_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_total_cleaning_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning count', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_count', + 'unique_id': '123456789ABCDEFGH_total_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_time', + 'unique_id': '123456789ABCDEFGH_total_clean_time', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_total_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 7adda900c02..f22f8d0cd36 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -219,6 +219,52 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_carpet_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.my_device_carpet_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Carpet boost', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carpet_boost', + 'unique_id': '123456789ABCDEFGH_carpet_boost', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_carpet_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Carpet boost', + }), + 'context': , + 'entity_id': 'switch.my_device_carpet_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.my_device_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..c0a48327e26 --- /dev/null +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -0,0 +1,96 @@ +# serializer version: 1 +# name: test_states[my_vacuum-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': , + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'model_id': None, + 'name': 'my_vacuum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- +# name: test_states[vacuum.my_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_speed_list': list([ + 'quiet', + 'max', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.my_vacuum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vacuum', + 'unique_id': '123456789ABCDEFGH-vacuum', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[vacuum.my_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_icon': 'mdi:battery-charging-100', + 'battery_level': 100, + 'fan_speed': 'max', + 'fan_speed_list': list([ + 'quiet', + 'max', + ]), + 'friendly_name': 'my_vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.my_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 565d4f1221a..7ae21fb4a26 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -61,7 +61,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( _mocked_device, diff --git a/tests/components/tplink/test_vacuum.py b/tests/components/tplink/test_vacuum.py new file mode 100644 index 00000000000..55bb8c0b504 --- /dev/null +++ b/tests/components/tplink/test_vacuum.py @@ -0,0 +1,133 @@ +"""Tests for vacuum platform.""" + +from __future__ import annotations + +from kasa import Device, Module +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, + DOMAIN as VACUUM_DOMAIN, + SERVICE_LOCATE, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + VacuumActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + translation, +) + +from . import DEVICE_ID, _mocked_device, setup_platform_for_device, snapshot_platform + +from tests.common import MockConfigEntry + +ENTITY_ID = "vacuum.my_vacuum" + + +@pytest.fixture +async def mocked_vacuum(hass: HomeAssistant) -> Device: + """Return mocked tplink vacuum.""" + + return _mocked_device(modules=[Module.Clean, Module.Speaker], alias="my_vacuum") + + +async def test_vacuum( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mocked_vacuum: Device, +) -> None: + """Test initialization.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.VACUUM, mocked_vacuum + ) + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert device_entries + + entity = entity_registry.async_get(ENTITY_ID) + assert entity + assert entity.unique_id == f"{DEVICE_ID}-vacuum" + + state = hass.states.get(ENTITY_ID) + assert state.state == VacuumActivity.DOCKED + + assert state.attributes[ATTR_FAN_SPEED] == "max" + assert state.attributes[ATTR_BATTERY_LEVEL] == 100 + result = translation.async_translate_state( + hass, "max", "vacuum", "tplink", "vacuum.state_attributes.fan_speed", None + ) + assert result == "Max" + + +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mocked_vacuum: Device, +) -> None: + """Test vacuum states.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.VACUUM, mocked_vacuum + ) + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("service_call", "module_name", "method", "params"), + [ + (SERVICE_START, Module.Clean, "start", {}), + (SERVICE_PAUSE, Module.Clean, "pause", {}), + (SERVICE_RETURN_TO_BASE, Module.Clean, "return_home", {}), + ( + SERVICE_SET_FAN_SPEED, + Module.Clean, + "set_fan_speed_preset", + {ATTR_FAN_SPEED: "quiet"}, + ), + (SERVICE_LOCATE, Module.Speaker, "locate", {}), + ], +) +async def test_vacuum_module( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_vacuum: Device, + service_call: str, + module_name: str, + method: str, + params: dict, +) -> None: + """Test that all vacuum commands work correctly.""" + vacuum = mocked_vacuum + module = vacuum.modules[module_name] + + await setup_platform_for_device(hass, mock_config_entry, Platform.VACUUM, vacuum) + + mock_method = getattr(module, method) + + service_data = {ATTR_ENTITY_ID: ENTITY_ID} + service_data |= params + + await hass.services.async_call( + VACUUM_DOMAIN, service_call, service_data, blocking=True + ) + + # Is this required when using blocking=True? + await hass.async_block_till_done(wait_background_tasks=True) + + mock_method.assert_called() diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 2159d92ae4b..d115546c9bc 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -23,7 +23,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DEFAULT_LANG, diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 702f8629219..ec7a0595731 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 6a493e32b02..94343d12ba2 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index b37e4f47137..39b70344db7 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -26,7 +26,7 @@ from homeassistant.components.unifi.const import ( from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index af134c7449b..5492f6fe0df 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ConfigEntryFactoryType, WebsocketStateManager diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 5e47d263079..ee8b102edaa 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -37,7 +37,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 352c33297ba..c49ade514bc 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -33,7 +33,7 @@ from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _patch_discovery from .utils import MockUFPFixture diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 5a1ffa8258e..7dd0362f17c 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityDescription from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 5be9cb3fe02..351e11db512 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( MediaClass, MediaPlayerEntityFeature, ) -import homeassistant.components.universal.media_player as universal +from homeassistant.components.universal import media_player as universal from homeassistant.const import ( SERVICE_RELOAD, STATE_OFF, diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index 202b3d32509..55138430ca0 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockUpdateEntity diff --git a/tests/components/upnp/test_binary_sensor.py b/tests/components/upnp/test_binary_sensor.py index 087cd9e9fb4..d9b5b442b00 100644 --- a/tests/components/upnp/test_binary_sensor.py +++ b/tests/components/upnp/test_binary_sensor.py @@ -6,7 +6,7 @@ from async_upnp_client.profiles.igd import IgdDevice, IgdState from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index e9d8a9cce8f..e7461c91da4 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 8f8ed672374..9730dba53d7 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -7,6 +7,7 @@ import os from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel +from aiousbwatcher import InotifyNotAvailableError import pytest from homeassistant.components import usb @@ -15,58 +16,29 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from . import conbee_device, slae_sh_device -from tests.common import import_and_test_deprecated_constant +from tests.common import async_fire_time_changed, import_and_test_deprecated_constant from tests.typing import WebSocketGenerator -@pytest.fixture(name="operating_system") -def mock_operating_system(): - """Mock running Home Assistant Operating system.""" +@pytest.fixture(name="aiousbwatcher_no_inotify") +def aiousbwatcher_no_inotify(): + """Patch AIOUSBWatcher to not use inotify.""" with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": True, - "docker": True, - }, + "homeassistant.components.usb.AIOUSBWatcher.async_start", + side_effect=InotifyNotAvailableError, ): yield -@pytest.fixture(name="docker") -def mock_docker(): - """Mock running Home Assistant in docker container.""" - with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": False, - "docker": True, - }, - ): - yield - - -@pytest.fixture(name="venv") -def mock_venv(): - """Mock running Home Assistant in a venv container.""" - with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": False, - "docker": False, - "virtualenv": True, - }, - ): - yield - - -async def test_observer_discovery( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +async def test_aiousbwatcher_discovery( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: - """Test that observer can discover a device without raising an exception.""" - new_usb = [{"domain": "test1", "vid": "3039"}] + """Test that aiousbwatcher can discover a device without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}, {"domain": "test2", "vid": "0FA0"}] mock_comports = [ MagicMock( @@ -78,26 +50,23 @@ async def test_observer_discovery( description=slae_sh_device.description, ) ] - mock_observer = None - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="add", device_path="/dev/new") - ) + aiousbwatcher_callback = None - def _create_mock_monitor_observer(monitor, callback, name): - nonlocal mock_observer - hass.create_task(_mock_monitor_observer_callback(callback)) - mock_observer = MagicMock() - return mock_observer + def async_register_callback(callback): + nonlocal aiousbwatcher_callback + aiousbwatcher_callback = callback + + MockAIOUSBWatcher = MagicMock() + MockAIOUSBWatcher.async_register_callback = async_register_callback with ( patch("sys.platform", "linux"), - patch("pyudev.Context"), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), - patch("pyudev.Monitor.filter_by"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch( + "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -105,18 +74,42 @@ async def test_observer_discovery( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert len(mock_config_flow.mock_calls) == 1 - assert mock_config_flow.mock_calls[0][1][0] == "test1" + assert aiousbwatcher_callback is not None - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + await hass.async_block_till_done() + assert len(mock_config_flow.mock_calls) == 1 - # pylint:disable-next=unnecessary-dunder-call - assert mock_observer.mock_calls == [call.start(), call.__bool__(), call.stop()] + mock_comports.append( + MagicMock( + device=slae_sh_device.device, + vid=4000, + pid=4000, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ) + + aiousbwatcher_callback() + await hass.async_block_till_done() + + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=usb.ADD_REMOVE_SCAN_COOLDOWN) + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[1][1][0] == "test2" + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_polling_discovery( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that polling can discover a device without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}] @@ -143,10 +136,6 @@ async def test_polling_discovery( with ( patch("sys.platform", "linux"), - patch( - "homeassistant.components.usb.USBDiscovery._get_monitor_observer", - return_value=None, - ), patch( "homeassistant.components.usb.POLLING_MONITOR_SCAN_PERIOD", timedelta(seconds=0.01), @@ -174,19 +163,9 @@ async def test_polling_discovery( await hass.async_block_till_done() -async def test_removal_by_observer_before_started( - hass: HomeAssistant, operating_system -) -> None: - """Test a device is removed by the observer before started.""" - - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="remove", device_path="/dev/new") - ) - - def _create_mock_monitor_observer(monitor, callback, name): - hass.async_create_task(_mock_monitor_observer_callback(callback)) - +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> None: + """Test a device is removed by the aiousbwatcher before started.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] mock_comports = [ @@ -203,7 +182,6 @@ async def test_removal_by_observer_before_started( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -219,6 +197,7 @@ async def test_removal_by_observer_before_started( await hass.async_block_till_done() +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -237,7 +216,6 @@ async def test_discovered_by_websocket_scan( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -256,6 +234,7 @@ async def test_discovered_by_websocket_scan( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -276,7 +255,6 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -295,6 +273,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_most_targeted_matcher_wins( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -316,7 +295,6 @@ async def test_most_targeted_matcher_wins( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -335,6 +313,7 @@ async def test_most_targeted_matcher_wins( assert mock_config_flow.mock_calls[0][1][0] == "more" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -355,7 +334,6 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -373,6 +351,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -398,7 +377,6 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -417,6 +395,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -437,7 +416,6 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -455,6 +433,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -480,7 +459,6 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -499,6 +477,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -524,7 +503,6 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -542,6 +520,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -562,7 +541,6 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -580,6 +558,7 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_match_vid_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -598,7 +577,6 @@ async def test_discovered_by_websocket_scan_match_vid_only( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -617,6 +595,7 @@ async def test_discovered_by_websocket_scan_match_vid_only( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_match_vid_wrong_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -635,7 +614,6 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -653,6 +631,7 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_no_vid_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -671,7 +650,6 @@ async def test_discovered_by_websocket_no_vid_pid( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -689,9 +667,9 @@ async def test_discovered_by_websocket_no_vid_pid( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.parametrize("exception_type", [ImportError, OSError]) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_non_matching_discovered_by_scanner_after_started( - hass: HomeAssistant, exception_type, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a websocket scan that does not match.""" new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] @@ -708,7 +686,6 @@ async def test_non_matching_discovered_by_scanner_after_started( ] with ( - patch("pyudev.Context", side_effect=exception_type), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -726,10 +703,10 @@ async def test_non_matching_discovered_by_scanner_after_started( assert len(mock_config_flow.mock_calls) == 0 -async def test_observer_on_wsl_fallback_without_throwing_exception( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: - """Test that observer on WSL failure results in fallback to scanning without raising an exception.""" + """Test that aiousbwatcher on WSL failure results in fallback to scanning without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}] mock_comports = [ @@ -744,8 +721,6 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( ] with ( - patch("pyudev.Context"), - patch("pyudev.Monitor.filter_by", side_effect=ValueError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -764,20 +739,8 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( assert mock_config_flow.mock_calls[0][1][0] == "test1" -async def test_not_discovered_by_observer_before_started_on_docker( - hass: HomeAssistant, docker -) -> None: - """Test a device is not discovered since observer is not running on bare docker.""" - - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="add", device_path="/dev/new") - ) - - def _create_mock_monitor_observer(monitor, callback, name): - hass.async_create_task(_mock_monitor_observer_callback(callback)) - return MagicMock() - +async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) -> None: + """Test a device is discovered since aiousbwatcher is now running.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] mock_comports = [ @@ -790,23 +753,45 @@ async def test_not_discovered_by_observer_before_started_on_docker( description=slae_sh_device.description, ) ] + initial_mock_comports = [] + aiousbwatcher_callback = None + + def async_register_callback(callback): + nonlocal aiousbwatcher_callback + aiousbwatcher_callback = callback + + MockAIOUSBWatcher = MagicMock() + MockAIOUSBWatcher.async_register_callback = async_register_callback with ( + patch("sys.platform", "linux"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), + patch( + "homeassistant.components.usb.comports", return_value=initial_mock_comports + ), + patch( + "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher + ), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() - with ( - patch("homeassistant.components.usb.comports", return_value=[]), - patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, - ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert len(mock_config_flow.mock_calls) == 0 + assert len(mock_config_flow.mock_calls) == 0 + + initial_mock_comports.extend(mock_comports) + aiousbwatcher_callback() + await hass.async_block_till_done() + + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=usb.ADD_REMOVE_SCAN_COOLDOWN) + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 1 def test_get_serial_by_id_no_dir() -> None: @@ -889,6 +874,7 @@ def test_human_readable_device_name() -> None: assert "8A2A" in name +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_async_is_plugged_in( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -912,7 +898,6 @@ async def test_async_is_plugged_in( } with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -935,6 +920,7 @@ async def test_async_is_plugged_in( assert usb.async_is_plugged_in(hass, matcher) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @pytest.mark.parametrize( "matcher", [ @@ -953,7 +939,6 @@ async def test_async_is_plugged_in_case_enforcement( new_usb = [{"domain": "test1", "vid": "ABCD"}] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -967,6 +952,7 @@ async def test_async_is_plugged_in_case_enforcement( usb.async_is_plugged_in(hass, matcher) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_web_socket_triggers_discovery_request_callbacks( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -974,7 +960,6 @@ async def test_web_socket_triggers_discovery_request_callbacks( mock_callback = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -1002,6 +987,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( assert len(mock_callback.mock_calls) == 1 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1010,7 +996,6 @@ async def test_initial_scan_callback( mock_callback_2 = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -1038,6 +1023,7 @@ async def test_initial_scan_callback( cancel_2() +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_cancel_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1045,7 +1031,6 @@ async def test_cancel_initial_scan_callback( mock_callback = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -1064,6 +1049,7 @@ async def test_cancel_initial_scan_callback( assert len(mock_callback.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_resolve_serial_by_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1082,7 +1068,6 @@ async def test_resolve_serial_by_id( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch( @@ -1106,6 +1091,7 @@ async def test_resolve_serial_by_id( assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/serial/by-id/bla" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @pytest.mark.parametrize( "ports", [ @@ -1190,7 +1176,6 @@ async def test_cp2102n_ordering_on_macos( with ( patch("sys.platform", "darwin"), - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -1239,6 +1224,7 @@ def test_deprecated_constants( ) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) async def test_register_port_event_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -1273,7 +1259,6 @@ async def test_register_port_event_callback( # Start off with no ports with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.comports", return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1335,6 +1320,7 @@ async def test_register_port_event_callback( assert mock_callback2.mock_calls == [] +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) async def test_register_port_event_callback_failure( hass: HomeAssistant, @@ -1371,7 +1357,6 @@ async def test_register_port_event_callback_failure( # Start off with no ports with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.comports", return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) diff --git a/tests/components/usgs_earthquakes_feed/test_geo_location.py b/tests/components/usgs_earthquakes_feed/test_geo_location.py index 40d19422ced..e412d53a0d0 100644 --- a/tests/components/usgs_earthquakes_feed/test_geo_location.py +++ b/tests/components/usgs_earthquakes_feed/test_geo_location.py @@ -35,7 +35,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index cd549c77913..eba7cf913db 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -12,9 +12,11 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) +from homeassistant.components.utility_meter import ( + select as um_select, + sensor as um_sensor, +) from homeassistant.components.utility_meter.const import DOMAIN, SERVICE_RESET -import homeassistant.components.utility_meter.select as um_select -import homeassistant.components.utility_meter.sensor as um_sensor from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -26,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, mock_restore_cache diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 348afac57f7..c671969c5ac 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -44,7 +44,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 3a0cbafb4a1..381cc1caa47 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/velbus/test_services.py b/tests/components/velbus/test_services.py index 2bcbac7b80d..94ba91e6dc3 100644 --- a/tests/components/velbus/test_services.py +++ b/tests/components/velbus/test_services.py @@ -18,7 +18,7 @@ from homeassistant.components.velbus.const import ( from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from . import init_integration diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index ead3ecdc173..ee9f9b94052 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -51,6 +51,9 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { ("post", "/inwallswitch/v1/device/devicedetail", "device-detail.json") ], "Dimmer Switch": [("post", "/dimmer/v1/device/devicedetail", "dimmer-detail.json")], + "SmartTowerFan": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "SmartTowerFan-detail.json") + ], } diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 8272da8dfad..a80c2631088 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -108,7 +108,31 @@ def outlet_fixture(): @pytest.fixture(name="humidifier") def humidifier_fixture(): """Create a mock VeSync humidifier fixture.""" - return Mock(VeSyncHumid200300S) + return Mock( + VeSyncHumid200300S, + cid="200s-humidifier", + config={ + "auto_target_humidity": 40, + "display": "true", + "automatic_stop": "true", + }, + details={ + "humidity": 35, + "mode": "manual", + }, + device_type="Classic200S", + device_name="Humidifier 200s", + device_status="on", + mist_level=6, + mist_modes=["auto", "manual"], + mode=None, + sub_device_no=0, + config_module="configModule", + connection_status="online", + current_firm_version="1.0.0", + water_lacks=False, + water_tank_lifted=False, + ) @pytest.fixture(name="humidifier_config_entry") diff --git a/tests/components/vesync/fixtures/SmartTowerFan-detail.json b/tests/components/vesync/fixtures/SmartTowerFan-detail.json new file mode 100644 index 00000000000..061dcb5b0d0 --- /dev/null +++ b/tests/components/vesync/fixtures/SmartTowerFan-detail.json @@ -0,0 +1,37 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "powerSwitch": 0, + "workMode": "normal", + "manualSpeedLevel": 1, + "fanSpeedLevel": 0, + "screenState": 0, + "screenSwitch": 0, + "oscillationSwitch": 1, + "oscillationState": 1, + "muteSwitch": 1, + "muteState": 1, + "timerRemain": 0, + "temperature": 717, + "humidity": 40, + "thermalComfort": 65, + "errorCode": 0, + "sleepPreference": { + "sleepPreferenceType": "default", + "oscillationSwitch": 0, + "initFanSpeedLevel": 0, + "fallAsleepRemain": 0, + "autoChangeFanLevelSwitch": 0 + }, + "scheduleCount": 0, + "displayingType": 0 + } + } +} diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index eac2bf9f5fa..3109fd3ea40 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -6,7 +6,7 @@ "cid": "200s-humidifier", "deviceType": "Classic200S", "deviceName": "Humidifier 200s", - "subDeviceNo": null, + "subDeviceNo": 4321, "deviceStatus": "on", "connectionStatus": "online", "uuid": "00000000-1111-2222-3333-444444444444", @@ -108,6 +108,15 @@ "deviceStatus": "on", "connectionStatus": "online", "configModule": "configModule" + }, + { + "cid": "smarttowerfan", + "deviceType": "LTF-F422S-KEU", + "deviceName": "SmartTowerFan", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online", + "configModule": "configModule" } ] } diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 1dea5f28f2c..fddc75630d2 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -477,7 +477,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, @@ -576,6 +576,109 @@ list([ ]) # --- +# name: test_fan_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[SmartTowerFan][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'advancedSleep', + 'auto', + 'turbo', + 'normal', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.smarttowerfan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vesync', + 'unique_id': 'smarttowerfan', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_fan_state[SmartTowerFan][fan.smarttowerfan] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'child_lock': False, + 'friendly_name': 'SmartTowerFan', + 'mode': 'normal', + 'night_light': 'off', + 'percentage': None, + 'percentage_step': 7.6923076923076925, + 'preset_mode': None, + 'preset_modes': list([ + 'advancedSleep', + 'auto', + 'turbo', + 'normal', + ]), + 'screen_status': False, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.smarttowerfan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_fan_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index ba6c7ab51b9..b89cf8cdd4d 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -348,7 +348,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, @@ -447,6 +447,44 @@ list([ ]) # --- +# name: test_light_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_light_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 50bee417a28..ca7a5cf3ea6 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -664,7 +664,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, @@ -715,7 +715,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '200s-humidifier-humidity', + 'unique_id': '200s-humidifier4321-humidity', 'unit_of_measurement': '%', }), ]) @@ -1155,6 +1155,44 @@ 'state': '0', }) # --- +# name: test_sensor_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_sensor_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 596aa0c94ad..ec9cbc4398c 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -242,7 +242,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, @@ -385,6 +385,44 @@ 'state': 'on', }) # --- +# name: test_switch_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_switch_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index b948053c3a0..25aa5337281 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -101,6 +101,9 @@ async def test_async_get_device_diagnostics__single_fan( "home_assistant.entities.2.state.last_changed": (str,), "home_assistant.entities.2.state.last_reported": (str,), "home_assistant.entities.2.state.last_updated": (str,), + "home_assistant.entities.3.state.last_changed": (str,), + "home_assistant.entities.3.state.last_reported": (str,), + "home_assistant.entities.3.state.last_updated": (str,), } ) ) diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py index 3b89ba8e742..b93c97baab6 100644 --- a/tests/components/vesync/test_humidifier.py +++ b/tests/components/vesync/test_humidifier.py @@ -1,6 +1,7 @@ """Tests for the humidifier platform.""" from contextlib import nullcontext +import logging from unittest.mock import patch import pytest @@ -12,7 +13,7 @@ from homeassistant.components.humidifier import ( SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -21,6 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er from .common import ( ENTITY_HUMIDIFIER, @@ -225,3 +227,61 @@ async def test_set_mode( ) await hass.async_block_till_done() method_mock.assert_called_once() + + +async def test_base_unique_id( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that unique_id is based on subDeviceNo.""" + # vesync-device.json defines subDeviceNo for 200s-humidifier as 4321. + entity = entity_registry.async_get(ENTITY_HUMIDIFIER) + assert entity.unique_id.endswith("4321") + + +async def test_invalid_mist_modes( + hass: HomeAssistant, + config_entry: ConfigEntry, + humidifier, + manager, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unsupported mist mode.""" + + humidifier.mist_modes = ["invalid_mode"] + + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + caplog.clear() + caplog.set_level(logging.WARNING) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'invalid_mode'" in caplog.text + + +async def test_valid_mist_modes( + hass: HomeAssistant, + config_entry: ConfigEntry, + humidifier, + manager, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test supported mist mode.""" + + humidifier.mist_modes = ["auto", "manual"] + + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + caplog.clear() + caplog.set_level(logging.WARNING) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'auto'" not in caplog.text + assert "Unknown mode 'manual'" not in caplog.text diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 3b0df128240..883e850fc62 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -48,6 +48,7 @@ async def test_async_setup_entry__no_devices( assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry assert setups_mock.call_args.args[1] == [ + Platform.BINARY_SENSOR, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, @@ -78,6 +79,7 @@ async def test_async_setup_entry__loads_fans( assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry assert setups_mock.call_args.args[1] == [ + Platform.BINARY_SENSOR, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, @@ -90,23 +92,36 @@ async def test_async_setup_entry__loads_fans( assert hass.data[DOMAIN][VS_DEVICES] == [fan] -async def test_async_new_device_discovery__loads_fans( - hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan +async def test_async_new_device_discovery( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan, humidifier ) -> None: - """Test setup connects to vesync and loads fan as an update call.""" + """Test new device discovery.""" assert await hass.config_entries.async_setup(config_entry.entry_id) # Assert platforms loaded await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED assert not hass.data[DOMAIN][VS_DEVICES] - fans = [fan] - manager.fans = fans - manager._dev_list = { - "fans": fans, - } - await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) - assert manager.login.call_count == 1 - assert hass.data[DOMAIN][VS_MANAGER] == manager - assert hass.data[DOMAIN][VS_DEVICES] == [fan] + # Mock discovery of new fan which would get added to VS_DEVICES. + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[fan], + ): + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert hass.data[DOMAIN][VS_DEVICES] == [fan] + + # Mock discovery of new humidifier which would invoke discovery in all platforms. + # The mocked humidifier needs to have all properties populated for correct processing. + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert hass.data[DOMAIN][VS_DEVICES] == [fan, humidifier] diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 17c9ee99320..ace22391797 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -2110,6 +2110,153 @@ 'state': '35.3', }) # --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_seasonal_performance_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seasonal performance factor', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_total', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_total', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.9', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_domestic_hot_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_domestic_hot_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seasonal performance factor - domestic hot water', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_dhw', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_dhw', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_domestic_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor - domestic hot water', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_domestic_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.1', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seasonal performance factor - heating', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_heating', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor - heating', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.2', + }) +# --- # name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_secondary_circuit_supply_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index e004255ec6d..9d776ba6a59 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.vizio.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import MOCK_SPEAKER_CONFIG, MOCK_USER_VALID_TV_CONFIG, UNIQUE_ID diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 17af2748c1c..442f4a62392 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -16,7 +16,7 @@ from homeassistant.components.assist_satellite import AssistSatelliteEntity # pylint: disable-next=hass-component-root-import from homeassistant.components.assist_satellite.entity import AssistSatelliteState -from homeassistant.components.voip import HassVoipDatagramProtocol +from homeassistant.components.voip import DOMAIN, HassVoipDatagramProtocol from homeassistant.components.voip.assist_satellite import Tones, VoipAssistSatellite from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.components.voip.voip import PreRecordMessageProtocol, make_protocol @@ -844,3 +844,243 @@ async def test_pipeline_error( assert sum(played_audio_bytes) > 0 assert played_audio_bytes == snapshot() + + +@pytest.mark.usefixtures("socket_enabled") +async def test_announce( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + ) as mock_send_tts, + ): + satellite.transport = Mock() + announce_task = hass.async_create_background_task( + satellite.async_announce(announcement), "voip_announce" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + + # Trigger announcement + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await announce_task + + mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_voip_id_is_ip_address( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement when VoIP is an IP address instead of a SIP header.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + with ( + patch.object(voip_device, "voip_id", "192.168.68.10"), + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + ) as mock_send_tts, + ): + satellite.transport = Mock() + announce_task = hass.async_create_background_task( + satellite.async_announce(announcement), "voip_announce" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + assert ( + mock_protocol.outgoing_call.call_args.kwargs["destination"].host + == "192.168.68.10" + ) + + # Trigger announcement + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await announce_task + + mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_announce_timeout( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement when user does not pick up the phone in time.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but some methods are not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + mock_protocol.cancel_call = Mock() + + # Very short timeout which will trigger because we don't send any audio in + with ( + patch( + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_RING_TIMEOUT", + 0.01, + ), + ): + satellite.transport = Mock() + with pytest.raises(TimeoutError): + await satellite.async_announce(announcement) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_start_conversation( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test start conversation.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + tts_sent = asyncio.Event() + + async def _send_tts(*args, **kwargs): + tts_sent.set() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context: Context, + *args, + device_id: str | None, + tts_audio_output: str | dict[str, Any] | None, + **kwargs, + ): + event_callback = kwargs["event_callback"] + + # Fake tts result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_START, + data={ + "engine": "test", + "language": hass.config.language, + "voice": "test", + "tts_input": "fake-text", + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.RUN_END + ) + ) + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + new=_send_tts, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + ): + satellite.transport = Mock() + conversation_task = hass.async_create_background_task( + satellite.async_start_conversation(announcement), "voip_start_conversation" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + + # Trigger announcement and wait for it to finish + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await tts_sent.wait() + + tts_sent.clear() + + # Trigger pipeline + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + # Wait for TTS + await tts_sent.wait() + await conversation_task diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py index f9f922b35d4..65be23fc168 100644 --- a/tests/components/vultr/test_sensor.py +++ b/tests/components/vultr/test_sensor.py @@ -4,8 +4,7 @@ import pytest import voluptuous as vol from homeassistant.components import vultr as base_vultr -from homeassistant.components.vultr import CONF_SUBSCRIPTION -import homeassistant.components.vultr.sensor as vultr +from homeassistant.components.vultr import CONF_SUBSCRIPTION, sensor as vultr from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 09a0a711582..67f0c1de36e 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -2,19 +2,20 @@ from __future__ import annotations +from typing import Any from unittest import mock from unittest.mock import AsyncMock, MagicMock import pytest import voluptuous as vol +from homeassistant.components import water_heater from homeassistant.components.water_heater import ( DOMAIN, SERVICE_SET_OPERATION_MODE, SET_TEMPERATURE_SCHEMA, WaterHeaterEntity, WaterHeaterEntityDescription, - WaterHeaterEntityEntityDescription, WaterHeaterEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -29,6 +30,7 @@ from tests.common import ( MockModule, MockPlatform, async_mock_service, + import_and_test_deprecated_constant, mock_integration, mock_platform, ) @@ -209,12 +211,27 @@ async def test_operation_mode_validation( @pytest.mark.parametrize( - ("class_name", "expected_log"), - [(WaterHeaterEntityDescription, False), (WaterHeaterEntityEntityDescription, True)], + ("constant_name", "replacement_name", "replacement"), + [ + ( + "WaterHeaterEntityEntityDescription", + "WaterHeaterEntityDescription", + WaterHeaterEntityDescription, + ), + ], ) -async def test_deprecated_entity_description( - caplog: pytest.LogCaptureFixture, class_name: type, expected_log: bool +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, ) -> None: - """Test deprecated WaterHeaterEntityEntityDescription logs warning.""" - class_name(key="test") - assert ("is a deprecated class" in caplog.text) is expected_log + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + water_heater, + constant_name, + replacement_name, + replacement, + "2026.1", + ) diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 78c0bd517a6..35a703cc109 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -1,4 +1,14 @@ # serializer version: 1 +# name: test_command + dict({ + 'media_player.lg_webos_tv_model': dict({ + 'muted': False, + 'returnValue': True, + 'scenario': 'mastervolume_tv_speaker_ext', + 'volume': 1, + }), + }) +# --- # name: test_entity_attributes StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -57,3 +67,11 @@ 'via_device_id': None, }) # --- +# name: test_select_sound_output + dict({ + 'media_player.lg_webos_tv_model': dict({ + 'method': 'setSystemSettings', + 'returnValue': True, + }), + }) +# --- diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index d5241dbe668..820ab856ebb 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -229,17 +229,30 @@ async def test_button(hass: HomeAssistant, client) -> None: client.button.assert_called_with("test") -async def test_command(hass: HomeAssistant, client) -> None: +async def test_command( + hass: HomeAssistant, + client, + snapshot: SnapshotAssertion, +) -> None: """Test generic command functionality.""" await setup_webostv(hass) + client.request.return_value = { + "returnValue": True, + "scenario": "mastervolume_tv_speaker_ext", + "volume": 1, + "muted": False, + } data = { ATTR_ENTITY_ID: ENTITY_ID, - ATTR_COMMAND: "test", + ATTR_COMMAND: "audio/getVolume", } - await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data, True) + response = await hass.services.async_call( + DOMAIN, SERVICE_COMMAND, data, True, return_response=True + ) await hass.async_block_till_done() - client.request.assert_called_with("test", payload=None) + client.request.assert_called_with("audio/getVolume", payload=None) + assert response == snapshot async def test_command_with_optional_arg(hass: HomeAssistant, client) -> None: @@ -258,17 +271,32 @@ async def test_command_with_optional_arg(hass: HomeAssistant, client) -> None: ) -async def test_select_sound_output(hass: HomeAssistant, client) -> None: +async def test_select_sound_output( + hass: HomeAssistant, + client, + snapshot: SnapshotAssertion, +) -> None: """Test select sound output service.""" await setup_webostv(hass) + client.change_sound_output.return_value = { + "returnValue": True, + "method": "setSystemSettings", + } data = { ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_OUTPUT: "external_speaker", } - await hass.services.async_call(DOMAIN, SERVICE_SELECT_SOUND_OUTPUT, data, True) + response = await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_SOUND_OUTPUT, + data, + True, + return_response=True, + ) await hass.async_block_till_done() client.change_sound_output.assert_called_once_with("external_speaker") + assert response == snapshot async def test_device_info_startup_off( diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index 94a34c96e2c..e01fbc07b51 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -3,8 +3,8 @@ from unittest.mock import MagicMock, patch import aiohttp -from aiohttp.client_exceptions import ClientConnectionError import pytest +from whirlpool.auth import AccountLockedError from homeassistant import config_entries from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN @@ -20,9 +20,22 @@ CONFIG_INPUT = { } +@pytest.fixture(name="mock_whirlpool_setup_entry") +def fixture_mock_whirlpool_setup_entry(): + """Set up async_setup_entry fixture.""" + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") async def test_form( - hass: HomeAssistant, region, brand, mock_backend_selector_api: MagicMock + hass: HomeAssistant, + region, + brand, + mock_backend_selector_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -32,14 +45,10 @@ async def test_form( assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "test-username" @@ -49,7 +58,7 @@ async def test_form( "region": region[0], "brand": brand[0], } - assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_whirlpool_setup_entry.mock_calls) == 1 mock_backend_selector_api.assert_called_once_with(brand[1], region[1]) @@ -70,19 +79,32 @@ async def test_form_invalid_auth( assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect( +@pytest.mark.usefixtures("mock_appliances_manager_api") +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (AccountLockedError, "account_locked"), + (aiohttp.ClientConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_auth_error( hass: HomeAssistant, + exception: Exception, + expected_error: str, region, brand, mock_auth_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_auth_api.return_value.do_auth.side_effect = aiohttp.ClientConnectionError - result2 = await hass.config_entries.flow.async_configure( + mock_auth_api.return_value.do_auth.side_effect = exception + result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG_INPUT | { @@ -90,56 +112,25 @@ async def test_form_cannot_connect( "brand": brand[0], }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} - -async def test_form_auth_timeout( - hass: HomeAssistant, - region, - brand, - mock_auth_api: MagicMock, -) -> None: - """Test we handle auth timeout error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - mock_auth_api.return_value.do_auth.side_effect = TimeoutError - result2 = await hass.config_entries.flow.async_configure( + # Test that it succeeds after the error is cleared + mock_auth_api.return_value.do_auth.side_effect = None + result = await hass.config_entries.flow.async_configure( result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_generic_auth_exception( - hass: HomeAssistant, - region, - brand, - mock_auth_api: MagicMock, -) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, ) - mock_auth_api.return_value.do_auth.side_effect = Exception - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + "username": "test-username", + "password": "test-password", + "region": region[0], + "brand": brand[0], + } + assert len(mock_whirlpool_setup_entry.mock_calls) == 1 @pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") @@ -167,7 +158,6 @@ async def test_form_already_configured(hass: HomeAssistant, region, brand) -> No "brand": brand[0], }, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" @@ -191,13 +181,14 @@ async def test_no_appliances_flow( result["flow_id"], CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "no_appliances"} -@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") +@pytest.mark.usefixtures( + "mock_auth_api", "mock_appliances_manager_api", "mock_whirlpool_setup_entry" +) async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: """Test a successful reauth flow.""" mock_entry = MockConfigEntry( @@ -213,14 +204,10 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" @@ -232,8 +219,8 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: } -@pytest.mark.usefixtures("mock_appliances_manager_api") -async def test_reauth_flow_auth_error( +@pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") +async def test_reauth_flow_invalid_auth( hass: HomeAssistant, region, brand, mock_auth_api: MagicMock ) -> None: """Test an authorization error reauth flow.""" @@ -251,22 +238,32 @@ async def test_reauth_flow_auth_error( assert result["errors"] == {} mock_auth_api.return_value.is_access_token_valid.return_value = False - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} -@pytest.mark.usefixtures("mock_appliances_manager_api") -async def test_reauth_flow_connnection_error( - hass: HomeAssistant, region, brand, mock_auth_api: MagicMock +@pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (AccountLockedError, "account_locked"), + (aiohttp.ClientConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_auth_error( + hass: HomeAssistant, + exception: Exception, + expected_error: str, + region, + brand, + mock_auth_api: MagicMock, ) -> None: """Test a connection error reauth flow.""" @@ -283,14 +280,10 @@ async def test_reauth_flow_connnection_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_auth_api.return_value.do_auth.side_effect = ClientConnectionError - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() + mock_auth_api.return_value.do_auth.side_effect = exception + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": expected_error} diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index f9d28e78a06..8f082ff6294 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import aiohttp +from whirlpool.auth import AccountLockedError from whirlpool.backendselector import Brand, Region from homeassistant.components.whirlpool.const import DOMAIN @@ -104,6 +105,18 @@ async def test_setup_auth_failed( assert entry.state is ConfigEntryState.SETUP_ERROR +async def test_setup_auth_account_locked( + hass: HomeAssistant, + mock_auth_api: MagicMock, + mock_aircon_api_instances: MagicMock, +) -> None: + """Test setup with failed auth due to account being locked.""" + mock_auth_api.return_value.do_auth.side_effect = AccountLockedError + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock, diff --git a/tests/components/whois/test_sensor.py b/tests/components/whois/test_sensor.py index d58cc342745..d290bc347a9 100644 --- a/tests/components/whois/test_sensor.py +++ b/tests/components/whois/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.whois.const import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 14e8b620983..e2935290f03 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/worldclock/test_sensor.py b/tests/components/worldclock/test_sensor.py index f901f605730..4941462cb14 100644 --- a/tests/components/worldclock/test_sensor.py +++ b/tests/components/worldclock/test_sensor.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.worldclock.const import CONF_TIME_FORMAT, DEFAULT_NAME from homeassistant.const import CONF_NAME, CONF_TIME_ZONE from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/wsdot/test_sensor.py b/tests/components/wsdot/test_sensor.py index 9f5ec92a5b6..ff3d4960735 100644 --- a/tests/components/wsdot/test_sensor.py +++ b/tests/components/wsdot/test_sensor.py @@ -5,7 +5,7 @@ import re import requests_mock -import homeassistant.components.wsdot.sensor as wsdot +from homeassistant.components.wsdot import sensor as wsdot from homeassistant.components.wsdot.sensor import ( ATTR_DESCRIPTION, ATTR_TIME_UPDATED, diff --git a/tests/components/xiaomi/test_device_tracker.py b/tests/components/xiaomi/test_device_tracker.py index 625e6f404ad..e3cc1898ce9 100644 --- a/tests/components/xiaomi/test_device_tracker.py +++ b/tests/components/xiaomi/test_device_tracker.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, call, patch import requests from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN -import homeassistant.components.xiaomi.device_tracker as xiaomi +from homeassistant.components.xiaomi import device_tracker as xiaomi from homeassistant.components.xiaomi.device_tracker import get_scanner from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/yale/test_binary_sensor.py b/tests/components/yale/test_binary_sensor.py index 811c845e359..16ec0ffbeb4 100644 --- a/tests/components/yale/test_binary_sensor.py +++ b/tests/components/yale/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yale/test_event.py b/tests/components/yale/test_event.py index 7aeb9d8f12b..ce7f2635eea 100644 --- a/tests/components/yale/test_event.py +++ b/tests/components/yale/test_event.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yale/test_lock.py b/tests/components/yale/test_lock.py index f6b96120d0d..1a99cf967ba 100644 --- a/tests/components/yale/test_lock.py +++ b/tests/components/yale/test_lock.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yandex_transport/test_sensor.py b/tests/components/yandex_transport/test_sensor.py index 13432850b2b..dd8e82278f3 100644 --- a/tests/components/yandex_transport/test_sensor.py +++ b/tests/components/yandex_transport/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components import sensor from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, load_fixture diff --git a/tests/components/youless/snapshots/test_sensor.ambr b/tests/components/youless/snapshots/test_sensor.ambr index 3424a264f48..0647d854d2a 100644 --- a/tests/components/youless/snapshots/test_sensor.ambr +++ b/tests/components/youless/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[sensor.energy_delivery_high-entry] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13,8 +13,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energy_delivery_high', - 'has_entity_name': False, + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,86 +24,33 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-import', - 'original_name': 'Energy delivery high', + 'original_icon': None, + 'original_name': 'Energy export tariff 1', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_delivery_high', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_delivery_high-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy delivery high', - 'icon': 'mdi:transmission-tower-import', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_delivery_high', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_sensors[sensor.energy_delivery_low-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energy_delivery_low', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-import', - 'original_name': 'Energy delivery low', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'youless_localhost_delivery_low', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_delivery_low-state] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Energy delivery low', - 'icon': 'mdi:transmission-tower-import', + 'friendly_name': 'Energy delivery meter Energy export tariff 1', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_delivery_low', + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.029', }) # --- -# name: test_sensors[sensor.energy_high-entry] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -117,8 +64,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energy_high', - 'has_entity_name': False, + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -128,242 +75,33 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-export', - 'original_name': 'Energy high', + 'original_icon': None, + 'original_name': 'Energy export tariff 2', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_high', + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'youless_localhost_delivery_high', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_high-state] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Energy high', - 'icon': 'mdi:transmission-tower-export', + 'friendly_name': 'Energy delivery meter Energy export tariff 2', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_high', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4490.631', - }) -# --- -# name: test_sensors[sensor.energy_low-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energy_low', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-export', - 'original_name': 'Energy low', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_low', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_low-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy low', - 'icon': 'mdi:transmission-tower-export', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_low', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4703.562', - }) -# --- -# name: test_sensors[sensor.energy_total-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energy_total', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-export', - 'original_name': 'Energy total', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_total', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_total-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy total', - 'icon': 'mdi:transmission-tower-export', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '9194.164', - }) -# --- -# name: test_sensors[sensor.extra_total-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.extra_total', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:meter-electric', - 'original_name': 'Extra total', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_extra_total', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.extra_total-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Extra total', - 'icon': 'mdi:meter-electric', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.extra_total', + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_sensors[sensor.extra_usage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.extra_usage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:lightning-bolt', - 'original_name': 'Extra usage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_extra_usage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.extra_usage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Extra usage', - 'icon': 'mdi:lightning-bolt', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.extra_usage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_sensors[sensor.gas_usage-entry] +# name: test_sensors[sensor.gas_meter_total_gas_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -377,8 +115,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.gas_usage', - 'has_entity_name': False, + 'entity_id': 'sensor.gas_meter_total_gas_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -388,34 +126,33 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:fire', - 'original_name': 'Gas usage', + 'original_icon': None, + 'original_name': 'Total gas usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_gas_m3', 'unique_id': 'youless_localhost_gas', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.gas_usage-state] +# name: test_sensors[sensor.gas_meter_total_gas_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'gas', - 'friendly_name': 'Gas usage', - 'icon': 'mdi:fire', + 'friendly_name': 'Gas meter Total gas usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.gas_usage', + 'entity_id': 'sensor.gas_meter_total_gas_usage', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1624.264', }) # --- -# name: test_sensors[sensor.phase_1_current-entry] +# name: test_sensors[sensor.power_meter_current_phase_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -429,8 +166,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_1_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_phase_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -441,32 +178,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 1 current', + 'original_name': 'Current phase 1', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_1_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_1_current-state] +# name: test_sensors[sensor.power_meter_current_phase_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 1 current', + 'friendly_name': 'Power meter Current phase 1', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_1_current', + 'entity_id': 'sensor.power_meter_current_phase_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensors[sensor.phase_1_power-entry] +# name: test_sensors[sensor.power_meter_current_phase_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -480,110 +217,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_1_power', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 1 power', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_1_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_1_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 1 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_1_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_1_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_1_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 1 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_1_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_1_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 1 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_1_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_2_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_2_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_phase_2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -594,32 +229,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 2 current', + 'original_name': 'Current phase 2', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_2_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_2_current-state] +# name: test_sensors[sensor.power_meter_current_phase_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 2 current', + 'friendly_name': 'Power meter Current phase 2', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_2_current', + 'entity_id': 'sensor.power_meter_current_phase_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensors[sensor.phase_2_power-entry] +# name: test_sensors[sensor.power_meter_current_phase_3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -633,110 +268,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_2_power', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 2 power', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_2_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_2_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 2 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_2_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_2_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_2_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 2 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_2_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_2_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 2 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_2_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_3_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_3_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_phase_3', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -747,32 +280,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 3 current', + 'original_name': 'Current phase 3', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_3_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_3_current-state] +# name: test_sensors[sensor.power_meter_current_phase_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 3 current', + 'friendly_name': 'Power meter Current phase 3', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_3_current', + 'entity_id': 'sensor.power_meter_current_phase_3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensors[sensor.phase_3_power-entry] +# name: test_sensors[sensor.power_meter_current_power_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -786,8 +319,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_3_power', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_power_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -798,135 +331,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 3 power', + 'original_name': 'Current power usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_3_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_3_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 3 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_3_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_3_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_3_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 3 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_3_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_3_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 3 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_3_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.power_usage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.power_usage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:meter-electric', - 'original_name': 'Power Usage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_power_w', 'unique_id': 'youless_localhost_usage', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.power_usage-state] +# name: test_sensors[sensor.power_meter_current_power_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Power Usage', - 'icon': 'mdi:meter-electric', + 'friendly_name': 'Power meter Current power usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.power_usage', + 'entity_id': 'sensor.power_meter_current_power_usage', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2382', }) # --- -# name: test_sensors[sensor.water_usage-entry] +# name: test_sensors[sensor.power_meter_energy_import_tariff_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -940,8 +370,569 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.water_usage', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'youless_localhost_power_low', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_energy_import_tariff_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4703.562', + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_energy_import_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'youless_localhost_power_high', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_energy_import_tariff_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4490.631', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_1_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_2_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 3', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_3_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_total_energy_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'youless_localhost_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_total_energy_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_total_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9194.164', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_1_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_2_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_3_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.s0_meter_current_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.s0_meter_current_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current usage', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_s0_w', + 'unique_id': 'youless_localhost_extra_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.s0_meter_current_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'S0 meter Current usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.s0_meter_current_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.s0_meter_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.s0_meter_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_s0_kwh', + 'unique_id': 'youless_localhost_extra_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.s0_meter_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'S0 meter Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.s0_meter_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.water_meter_total_water_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_meter_total_water_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -951,27 +942,26 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:water', - 'original_name': 'Water usage', + 'original_icon': None, + 'original_name': 'Total water usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_water', 'unique_id': 'youless_localhost_water', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.water_usage-state] +# name: test_sensors[sensor.water_meter_total_water_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'water', - 'friendly_name': 'Water usage', - 'icon': 'mdi:water', + 'friendly_name': 'Water meter Total water usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.water_usage', + 'entity_id': 'sensor.water_meter_total_water_usage', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/zerproc/test_light.py b/tests/components/zerproc/test_light.py index 724414b5965..6cadc025385 100644 --- a/tests/components/zerproc/test_light.py +++ b/tests/components/zerproc/test_light.py @@ -29,7 +29,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 1dd1e5f81aa..89526f6431e 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -9,7 +9,7 @@ import zigpy.zcl.foundation as zcl_f from homeassistant.components.zha.helpers import ZHADeviceProxy from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 1b280ea499a..78d335469b8 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -25,7 +25,7 @@ from zigpy.zcl.clusters.general import Basic, Groups from zigpy.zcl.foundation import Status import zigpy.zdo.types as zdo_t -import homeassistant.components.zha.const as zha_const +from homeassistant.components.zha import const as zha_const from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index ae96de44f17..8a587966f81 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -18,7 +18,7 @@ from homeassistant.components.zha.helpers import ( ) from homeassistant.const import STATE_HOME, STATE_NOT_HOME, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE diff --git a/tests/components/zha/test_helpers.py b/tests/components/zha/test_helpers.py index f8a809df51e..f52b403869e 100644 --- a/tests/components/zha/test_helpers.py +++ b/tests/components/zha/test_helpers.py @@ -9,7 +9,7 @@ from zigpy.application import ControllerApplication from zigpy.types.basic import uint16_t from zigpy.zcl.clusters import lighting -import homeassistant.components.zha.const as zha_const +from homeassistant.components.zha import const as zha_const from homeassistant.components.zha.helpers import ( cluster_command_schema_to_vol_schema, convert_to_zcl_values, @@ -18,7 +18,7 @@ from homeassistant.components.zha.helpers import ( get_zha_data, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index f9837a7d016..5849cc6f233 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -28,7 +28,7 @@ from homeassistant.components.zha.helpers import ( ) from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import find_entity_id diff --git a/tests/components/zodiac/test_sensor.py b/tests/components/zodiac/test_sensor.py index 19b9733e4f5..880e5c889ec 100644 --- a/tests/components/zodiac/test_sensor.py +++ b/tests/components/zodiac/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d237a6e410a..a46320168eb 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -10,8 +10,7 @@ from zwave_js_server.model.node import Node from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import device_registry as dr, issue_registry as ir from tests.components.repairs import ( async_process_repairs_platforms, diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index ddfd205b017..d26cccbc7d5 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -10,7 +10,7 @@ from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import replace_value_of_zwave_value diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 10ea64a6a61..4c6bc930667 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -1,7 +1,10 @@ """Test methods in backup_restore.""" +from collections.abc import Generator +import json from pathlib import Path import tarfile +from typing import Any from unittest import mock import pytest @@ -11,6 +14,23 @@ from homeassistant import backup_restore from .common import get_test_config_dir +@pytest.fixture(autouse=True) +def remove_restore_result_file() -> Generator[None, Any, Any]: + """Remove the restore result file.""" + yield + Path(get_test_config_dir(".HA_RESTORE_RESULT")).unlink(missing_ok=True) + + +def restore_result_file_content() -> dict[str, Any] | None: + """Return the content of the restore result file.""" + try: + return json.loads( + Path(get_test_config_dir(".HA_RESTORE_RESULT")).read_text("utf-8") + ) + except FileNotFoundError: + return None + + @pytest.mark.parametrize( ("side_effect", "content", "expected"), [ @@ -87,6 +107,11 @@ def test_restoring_backup_that_does_not_exist() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() == { + "error": f"Backup file {backup_file_path} does not exist", + "error_type": "ValueError", + "success": False, + } def test_restoring_backup_when_instructions_can_not_be_read() -> None: @@ -98,6 +123,7 @@ def test_restoring_backup_when_instructions_can_not_be_read() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() is None def test_restoring_backup_that_is_not_a_file() -> None: @@ -121,6 +147,11 @@ def test_restoring_backup_that_is_not_a_file() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() == { + "error": f"Backup file {backup_file_path} does not exist", + "error_type": "ValueError", + "success": False, + } def test_aborting_for_older_versions() -> None: @@ -152,6 +183,13 @@ def test_aborting_for_older_versions() -> None: ), ): assert backup_restore.restore_backup(config_dir) is True + assert restore_result_file_content() == { + "error": ( + "You need at least Home Assistant version 9999.99.99 to restore this backup" + ), + "error_type": "ValueError", + "success": False, + } @pytest.mark.parametrize( @@ -280,6 +318,11 @@ def test_removal_of_current_configuration_when_restoring( assert removed_directories == { Path(config_dir, d) for d in expected_removed_directories } + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } def test_extracting_the_contents_of_a_backup_file() -> None: @@ -332,6 +375,11 @@ def test_extracting_the_contents_of_a_backup_file() -> None: assert { member.name for member in extractall_mock.mock_calls[-1].kwargs["members"] } == {"data", "data/.HA_VERSION", "data/.storage", "data/www"} + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } @pytest.mark.parametrize( @@ -362,6 +410,11 @@ def test_remove_backup_file_after_restore( assert mock_unlink.call_count == unlink_calls for call in mock_unlink.mock_calls: assert call.args[0] == backup_file_path + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } @pytest.mark.parametrize(